@topy-ai/maggie 0.7.26 → 0.7.28
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.
- package/README.md +50 -4
- package/README.zh-TW.md +4 -2
- package/bin/maggie.js +17 -5
- package/bundled-contracts/maggie-deployment/runtime-parity-v1.schema.json +20 -2
- package/bundled-contracts/maggie-seo/model-policy-v1.schema.json +17 -0
- package/bundled-contracts/maggie-service-booking/resolver-v1.schema.json +17 -0
- package/bundled-contracts/maggie-service-booking/worker-health-v1.schema.json +17 -0
- package/bundled-contracts/maggiedash/README.md +5 -0
- package/bundled-contracts/maggiedash/migration-preflight-v1.schema.json +16 -0
- package/bundled-references/blog-translation-ingestion.md +7 -0
- package/bundled-references/maggiedash-integration.md +19 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +21 -0
- package/bundled-skills/maggie-dash/SKILL.md +34 -0
- package/bundled-skills/maggie-deployment/SKILL.md +22 -7
- package/bundled-skills/maggie-deployment/references/vps.md +12 -2
- package/bundled-skills/maggie-feedback/SKILL.md +13 -3
- package/bundled-skills/maggie-marketplace/SKILL.md +20 -1
- package/bundled-skills/maggie-seo-geo/SKILL.md +11 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +19 -0
- package/bundled-tools/clis/maggie.py +29 -1
- package/bundled-tools/clis/maggie_dash.py +108 -0
- package/bundled-tools/clis/maggie_deployment.py +43 -4
- package/bundled-tools/clis/maggie_deployment_parity.py +42 -0
- package/bundled-tools/clis/maggie_feedback.py +108 -0
- package/bundled-tools/clis/maggie_localization.py +18 -1
- package/bundled-tools/clis/maggie_marketplace.py +58 -4
- package/bundled-tools/clis/maggie_migration.py +43 -0
- package/bundled-tools/clis/maggie_service_booking.py +84 -1
- package/bundled-tools/integrations/maggie-api-pull.md +8 -4
- package/bundled-tools/runtime/maggie_dash_store.py +20 -0
- package/package.json +1 -1
- package/references/blog-translation-ingestion.md +7 -0
- package/references/maggiedash-integration.md +19 -0
|
@@ -7,6 +7,25 @@ metadata:
|
|
|
7
7
|
|
|
8
8
|
# Maggie Service Booking
|
|
9
9
|
|
|
10
|
+
## Bounded worker and resolver evidence
|
|
11
|
+
|
|
12
|
+
Before enabling provider crawling or URL resolution, validate sanitized host
|
|
13
|
+
evidence. These checks keep queue depth, leases, concurrency, timeouts,
|
|
14
|
+
retries, cancellation cleanup, and worker health explicit:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
maggie service worker-health --project . \
|
|
18
|
+
--evidence .maggie/booking/worker-health-input.json
|
|
19
|
+
maggie service resolver-audit --project . \
|
|
20
|
+
--evidence .maggie/booking/resolver-input.json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The worker report uses `maggie-booking-worker-health.v1`. Resolver outcomes are
|
|
24
|
+
limited to `resolved`, `resolved-no-menu`, `not-found`, `blocked`, `timeout`,
|
|
25
|
+
and `unavailable`; resolved outcomes require a public canonical HTTPS URL and
|
|
26
|
+
safe evidence IDs. Raw provider responses, credentials, logs, and request
|
|
27
|
+
bodies are never copied into the reports.
|
|
28
|
+
|
|
10
29
|
Route matching evidence must come from the route source's actual import
|
|
11
30
|
statements. The shared resolver in `tools/runtime/route_imports.py` resolves
|
|
12
31
|
relative imports and records a component fingerprint; it never selects a
|
|
@@ -426,8 +426,29 @@ def command_api_lifecycle(args: argparse.Namespace) -> int:
|
|
|
426
426
|
"total_posts", "matchedPosts", "matched_posts", "eligible",
|
|
427
427
|
"unmatched", "queued", "accepted", "created", "updated",
|
|
428
428
|
"skipped", "removed", "nextCursor", "next_cursor",
|
|
429
|
+
"message", "error", "quota_exhausted", "quotaExhausted",
|
|
430
|
+
"quotaDailyRemaining", "quota_daily_remaining",
|
|
431
|
+
"quotaMonthlyRemaining", "quota_monthly_remaining",
|
|
429
432
|
)
|
|
430
|
-
summary = {
|
|
433
|
+
summary = {}
|
|
434
|
+
for key in allowed:
|
|
435
|
+
if key not in value or not isinstance(value[key], (str, int, float, bool, type(None))):
|
|
436
|
+
continue
|
|
437
|
+
item = value[key]
|
|
438
|
+
if key in {"message", "error"} and isinstance(item, str):
|
|
439
|
+
item = " ".join(item.split())[:300]
|
|
440
|
+
summary[key] = item
|
|
441
|
+
quota = value.get("quota")
|
|
442
|
+
if isinstance(quota, dict):
|
|
443
|
+
quota_summary = {}
|
|
444
|
+
for window_name, window in quota.items():
|
|
445
|
+
if not isinstance(window, dict) or not isinstance(window.get("remaining"), int):
|
|
446
|
+
continue
|
|
447
|
+
quota_summary[str(window_name)] = {"remaining": window["remaining"]}
|
|
448
|
+
if quota_summary:
|
|
449
|
+
summary["quota"] = quota_summary
|
|
450
|
+
if value.get("quota_exhausted") is True or value.get("quotaExhausted") is True:
|
|
451
|
+
summary["effective_delivery_remaining"] = 0
|
|
431
452
|
items = value.get("items")
|
|
432
453
|
if isinstance(items, list):
|
|
433
454
|
summary["item_count"] = len(items)
|
|
@@ -639,6 +660,13 @@ def interview_value(key: str, question: str, current: str, choices: list[str]) -
|
|
|
639
660
|
def command_bootstrap_interview(args: argparse.Namespace) -> int:
|
|
640
661
|
root = Path(args.project).resolve()
|
|
641
662
|
analysis = detect(root)
|
|
663
|
+
if not args.non_interactive and not sys.stdin.isatty():
|
|
664
|
+
print(
|
|
665
|
+
"NON_INTERACTIVE_REQUIRED: stdin is not interactive; use --non-interactive "
|
|
666
|
+
"--answers-file answers.json --confirm after reviewing the proposal.",
|
|
667
|
+
file=sys.stderr,
|
|
668
|
+
)
|
|
669
|
+
return 2
|
|
642
670
|
answers: dict[str, str] = {}
|
|
643
671
|
if args.answers_file:
|
|
644
672
|
try:
|
|
@@ -102,8 +102,38 @@ def manifest_file_pairs(source: Path, manifest: dict[str, object], target: Path)
|
|
|
102
102
|
return pairs
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def manifest_root_file_pairs(source: Path, manifest: dict[str, object], root: Path) -> list[tuple[Path, Path, Path]]:
|
|
106
|
+
"""Return optional host-root files without changing the admin target contract."""
|
|
107
|
+
pairs: list[tuple[Path, Path, Path]] = []
|
|
108
|
+
for item in manifest.get("rootFiles", []):
|
|
109
|
+
if isinstance(item, str):
|
|
110
|
+
source_relative = Path(item)
|
|
111
|
+
target_relative = source_relative
|
|
112
|
+
elif isinstance(item, dict):
|
|
113
|
+
source_relative = Path(str(item.get("source", "")))
|
|
114
|
+
target_relative = Path(str(item.get("target", "")))
|
|
115
|
+
else:
|
|
116
|
+
raise RuntimeError("MaggieDash rootFiles entries must be strings or objects")
|
|
117
|
+
if not source_relative.parts or not target_relative.parts:
|
|
118
|
+
raise RuntimeError("MaggieDash rootFiles entries require source and target")
|
|
119
|
+
if source_relative.is_absolute() or target_relative.is_absolute() or ".." in source_relative.parts or ".." in target_relative.parts:
|
|
120
|
+
raise RuntimeError("unsafe MaggieDash rootFiles path")
|
|
121
|
+
source_item = source / source_relative
|
|
122
|
+
if source_item.is_file():
|
|
123
|
+
pairs.append((source_item, root / target_relative, source_relative))
|
|
124
|
+
continue
|
|
125
|
+
if not source_item.is_dir():
|
|
126
|
+
raise RuntimeError(f"MaggieDash rootFiles source is missing: {source_relative}")
|
|
127
|
+
for path in sorted(source_item.rglob("*")):
|
|
128
|
+
if path.is_file():
|
|
129
|
+
child = path.relative_to(source_item)
|
|
130
|
+
pairs.append((path, root / target_relative / child, source_relative / child))
|
|
131
|
+
return pairs
|
|
132
|
+
|
|
133
|
+
|
|
105
134
|
def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool) -> dict[str, object]:
|
|
106
135
|
pairs = manifest_file_pairs(source, manifest, target)
|
|
136
|
+
root_pairs = manifest_root_file_pairs(source, manifest, target.parents[1])
|
|
107
137
|
files: list[dict[str, str]] = []
|
|
108
138
|
source_relative = {relative for _, _, relative in pairs}
|
|
109
139
|
for source_file, destination, relative in pairs:
|
|
@@ -119,6 +149,15 @@ def command_install_diff(source: Path, manifest: dict[str, object], target: Path
|
|
|
119
149
|
else:
|
|
120
150
|
action = "update" if force else "preserve"
|
|
121
151
|
files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
|
|
152
|
+
for source_file, destination, relative in root_pairs:
|
|
153
|
+
compare_file = destination
|
|
154
|
+
if not compare_file.exists():
|
|
155
|
+
action = "add"
|
|
156
|
+
elif compare_file.read_bytes() == source_file.read_bytes():
|
|
157
|
+
action = "unchanged"
|
|
158
|
+
else:
|
|
159
|
+
action = "update" if force else "preserve"
|
|
160
|
+
files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
|
|
122
161
|
if compare_dir and compare_dir.exists():
|
|
123
162
|
for existing in sorted(compare_dir.rglob("*")):
|
|
124
163
|
if not existing.is_file():
|
|
@@ -204,6 +243,13 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
204
243
|
current_installed = [str(destination)]
|
|
205
244
|
installed.extend(current_installed)
|
|
206
245
|
preserved.extend(current_preserved)
|
|
246
|
+
for source_file, destination, _ in manifest_root_file_pairs(source, manifest, root):
|
|
247
|
+
if destination.exists() and not args.force:
|
|
248
|
+
preserved.append(str(destination))
|
|
249
|
+
else:
|
|
250
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
shutil.copy2(source_file, destination)
|
|
252
|
+
installed.append(str(destination))
|
|
207
253
|
state = {
|
|
208
254
|
"schemaVersion": "maggiedash-install.v1",
|
|
209
255
|
"status": "installed",
|
|
@@ -442,6 +488,62 @@ def command_api_contract(args: argparse.Namespace) -> int:
|
|
|
442
488
|
return 0 if result["passed"] else 1
|
|
443
489
|
|
|
444
490
|
|
|
491
|
+
def command_conformance(args: argparse.Namespace) -> int:
|
|
492
|
+
"""Validate sanitized runtime evidence for every declared host endpoint."""
|
|
493
|
+
project = Path(getattr(args, "project", ".")).resolve()
|
|
494
|
+
def input_path(value: str) -> Path:
|
|
495
|
+
path = Path(value)
|
|
496
|
+
return path if path.is_absolute() else project / path
|
|
497
|
+
contract = json.loads(input_path(args.contract).read_text(encoding="utf-8"))
|
|
498
|
+
evidence = json.loads(input_path(args.evidence).read_text(encoding="utf-8"))
|
|
499
|
+
endpoints = contract.get("x-endpoints") or contract.get("endpoints") or contract.get("properties", {}).get("endpoints", {}).get("x-endpoints", [])
|
|
500
|
+
observations = evidence.get("observations") if isinstance(evidence, dict) else None
|
|
501
|
+
errors: list[str] = []
|
|
502
|
+
if not isinstance(endpoints, list) or not endpoints:
|
|
503
|
+
errors.append("contract must declare at least one endpoint")
|
|
504
|
+
endpoints = []
|
|
505
|
+
if not isinstance(observations, list):
|
|
506
|
+
errors.append("evidence.observations must be an array")
|
|
507
|
+
observations = []
|
|
508
|
+
by_id = {str(item.get("id")): item for item in observations if isinstance(item, dict) and item.get("id")}
|
|
509
|
+
checked = []
|
|
510
|
+
for endpoint in endpoints:
|
|
511
|
+
endpoint_id = str(endpoint.get("id", "")) if isinstance(endpoint, dict) else ""
|
|
512
|
+
item_errors: list[str] = []
|
|
513
|
+
observation = by_id.get(endpoint_id)
|
|
514
|
+
if not observation:
|
|
515
|
+
item_errors.append("missing runtime observation")
|
|
516
|
+
else:
|
|
517
|
+
if observation.get("method") != endpoint.get("method"):
|
|
518
|
+
item_errors.append("observed method does not match contract")
|
|
519
|
+
if observation.get("route") != endpoint.get("route"):
|
|
520
|
+
item_errors.append("observed route does not match contract")
|
|
521
|
+
status = observation.get("httpStatus")
|
|
522
|
+
if not isinstance(status, int) or isinstance(status, bool) or not 200 <= status < 300:
|
|
523
|
+
item_errors.append("endpoint did not return a 2xx status")
|
|
524
|
+
expected_type = endpoint.get("response", {}).get("contentType")
|
|
525
|
+
if observation.get("contentType") != expected_type:
|
|
526
|
+
item_errors.append("observed content type does not match contract")
|
|
527
|
+
fields = observation.get("responseFields")
|
|
528
|
+
required = endpoint.get("response", {}).get("requiredFields", [])
|
|
529
|
+
if not isinstance(fields, list) or not set(required).issubset(set(fields)):
|
|
530
|
+
item_errors.append("response is missing one or more declared fields")
|
|
531
|
+
if observation.get("passed") is not True:
|
|
532
|
+
item_errors.append("runtime observation is not passed")
|
|
533
|
+
checked.append({"id": endpoint_id, "passed": not item_errors, "errors": item_errors})
|
|
534
|
+
duplicate_ids = len(by_id) != len(observations)
|
|
535
|
+
if duplicate_ids:
|
|
536
|
+
errors.append("evidence contains duplicate or invalid observation ids")
|
|
537
|
+
report = {"schemaVersion": "maggiedash-host-conformance.v1", "checked": len(checked), "endpoints": checked, "errors": errors, "passed": not errors and bool(checked) and all(item["passed"] for item in checked)}
|
|
538
|
+
if args.output:
|
|
539
|
+
output = input_path(args.output)
|
|
540
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
542
|
+
report["output"] = str(output)
|
|
543
|
+
emit(report)
|
|
544
|
+
return 0 if report["passed"] else 1
|
|
545
|
+
|
|
546
|
+
|
|
445
547
|
def command_schema_audit(args: argparse.Namespace) -> int:
|
|
446
548
|
value = json.loads(Path(args.inventory).resolve().read_text(encoding="utf-8"))
|
|
447
549
|
result = audit_schema_inventory(value, fail_on_unread=args.fail_on_unread)
|
|
@@ -603,6 +705,12 @@ def parser() -> argparse.ArgumentParser:
|
|
|
603
705
|
api_contract = sub.add_parser("api-contract", help="validate a local or live declared request and success-response contract")
|
|
604
706
|
api_contract.add_argument("--spec", required=True, help="local JSON file or HTTPS URL (loopback HTTP is allowed for development)")
|
|
605
707
|
api_contract.set_defaults(func=command_api_contract)
|
|
708
|
+
conformance = sub.add_parser("conformance", help="validate one sanitized runtime observation for every declared MaggieDash endpoint")
|
|
709
|
+
conformance.add_argument("--project", default=".", help="host project root for relative contract, evidence, and output paths")
|
|
710
|
+
conformance.add_argument("--contract", required=True, help="MaggieDash host adapter contract JSON")
|
|
711
|
+
conformance.add_argument("--evidence", required=True, help="sanitized endpoint observations JSON")
|
|
712
|
+
conformance.add_argument("--output")
|
|
713
|
+
conformance.set_defaults(func=command_conformance)
|
|
606
714
|
schema_audit = sub.add_parser("schema-audit", help="find declared tables with missing reader evidence")
|
|
607
715
|
schema_audit.add_argument("--inventory", required=True, help="host-produced schema inventory JSON")
|
|
608
716
|
schema_audit.add_argument("--fail-on-unread", action="store_true")
|
|
@@ -55,8 +55,7 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
55
55
|
rollback_passed = False
|
|
56
56
|
if rollback_evidence.exists():
|
|
57
57
|
try:
|
|
58
|
-
|
|
59
|
-
rollback_passed = bool(evidence.get("passed") is True and evidence.get("environment") == "production" and evidence.get("previousRelease") and evidence.get("testedAt"))
|
|
58
|
+
rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8"))) ["passed"]
|
|
60
59
|
except (OSError, json.JSONDecodeError):
|
|
61
60
|
rollback_passed = False
|
|
62
61
|
migration_manifest = project / ".maggie" / "migration-manifest.json"
|
|
@@ -79,12 +78,14 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
79
78
|
return result
|
|
80
79
|
|
|
81
80
|
|
|
82
|
-
def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> dict:
|
|
81
|
+
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None) -> dict:
|
|
83
82
|
"""Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
|
|
84
83
|
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
|
|
85
84
|
raise ValueError("domain must be a hostname without a scheme or path")
|
|
86
85
|
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
|
|
87
86
|
raise ValueError("service must contain only safe systemd name characters")
|
|
87
|
+
if node_port is None:
|
|
88
|
+
raise ValueError("node port must be supplied explicitly; choose an unused port for this host")
|
|
88
89
|
if not 1024 <= node_port <= 65535:
|
|
89
90
|
raise ValueError("node port must be between 1024 and 65535")
|
|
90
91
|
root = release_root.rstrip("/")
|
|
@@ -97,6 +98,12 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
|
|
|
97
98
|
"release_root": root,
|
|
98
99
|
"current_release": current,
|
|
99
100
|
"node_port": node_port,
|
|
101
|
+
"proxy": {
|
|
102
|
+
"trust_forwarded_proto": True,
|
|
103
|
+
"forwarded_headers": ["X-Forwarded-Host", "X-Forwarded-Proto"],
|
|
104
|
+
"origin_check": "Astro security.checkOrigin remains enabled",
|
|
105
|
+
"allowed_domains_required": True,
|
|
106
|
+
},
|
|
100
107
|
"release_layout": f"{root}/releases/<immutable-release> plus current symlink",
|
|
101
108
|
"retention": {"keep": 2, "preserve": [current, f"{root}/releases/<previous-release>"], "prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf --"},
|
|
102
109
|
"staging_first": True,
|
|
@@ -141,6 +148,32 @@ def validate_data_checkpoint(path: Path) -> dict:
|
|
|
141
148
|
return {"passed": not errors, "declared": True, "errors": errors}
|
|
142
149
|
|
|
143
150
|
|
|
151
|
+
def validate_rollback_evidence(value: object) -> dict:
|
|
152
|
+
"""Validate release rollback evidence, including the explicitly safe first release."""
|
|
153
|
+
if not isinstance(value, dict):
|
|
154
|
+
return {"passed": False, "errors": ["rollback evidence must be an object"]}
|
|
155
|
+
errors = []
|
|
156
|
+
if value.get("schemaVersion") not in {None, "maggie-deployment-rollback.v1"}:
|
|
157
|
+
errors.append("rollback evidence schemaVersion is unsupported")
|
|
158
|
+
if value.get("passed") is not True:
|
|
159
|
+
errors.append("rollback evidence is not passed")
|
|
160
|
+
if value.get("environment") != "production":
|
|
161
|
+
errors.append("rollback evidence must target production")
|
|
162
|
+
if not value.get("testedAt"):
|
|
163
|
+
errors.append("rollback evidence testedAt is required")
|
|
164
|
+
release_kind = value.get("releaseKind", "upgrade")
|
|
165
|
+
if release_kind not in {"first", "upgrade"}:
|
|
166
|
+
errors.append("rollback evidence releaseKind must be first or upgrade")
|
|
167
|
+
if release_kind == "first":
|
|
168
|
+
if value.get("previousRelease"):
|
|
169
|
+
errors.append("first release evidence must not claim a previous release")
|
|
170
|
+
if not value.get("notApplicableReason"):
|
|
171
|
+
errors.append("first release evidence needs notApplicableReason")
|
|
172
|
+
elif not value.get("previousRelease"):
|
|
173
|
+
errors.append("upgrade rollback evidence requires previousRelease")
|
|
174
|
+
return {"passed": not errors, "errors": errors}
|
|
175
|
+
|
|
176
|
+
|
|
144
177
|
def validate_scheduler_request(method: str, origin: str | None, has_auth: bool) -> dict:
|
|
145
178
|
errors = []
|
|
146
179
|
if method.upper() == "POST":
|
|
@@ -210,6 +243,7 @@ WantedBy=multi-user.target
|
|
|
210
243
|
proxy_set_header Host $host;
|
|
211
244
|
proxy_set_header X-Real-IP $remote_addr;
|
|
212
245
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
246
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
213
247
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
214
248
|
}}
|
|
215
249
|
}}
|
|
@@ -230,10 +264,15 @@ set -euo pipefail
|
|
|
230
264
|
|
|
231
265
|
# Migration precedes the symlink flip; later releases carry agent state.
|
|
232
266
|
: "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
|
|
267
|
+
RELEASE_KIND="${{RELEASE_KIND:-upgrade}}"
|
|
233
268
|
RELEASE_ROOT="{root}"
|
|
234
269
|
CURRENT="{current}"
|
|
235
270
|
RELEASE="$RELEASE_ROOT/releases/$RELEASE_ID"
|
|
236
271
|
PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
|
272
|
+
if [ "$RELEASE_KIND" != "first" ] && [ -z "$PREVIOUS" ]; then
|
|
273
|
+
echo "No previous release found; set RELEASE_KIND=first for the initial production release." >&2
|
|
274
|
+
exit 2
|
|
275
|
+
fi
|
|
237
276
|
mkdir -p "$RELEASE"
|
|
238
277
|
cd "$RELEASE"
|
|
239
278
|
|
|
@@ -277,7 +316,7 @@ def main() -> int:
|
|
|
277
316
|
parser.add_argument("--domain", help="canonical hostname for --vps-plan")
|
|
278
317
|
parser.add_argument("--service", default="maggie-site", help="systemd service name")
|
|
279
318
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
280
|
-
parser.add_argument("--node-port", type=int, default=
|
|
319
|
+
parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
|
|
281
320
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
282
321
|
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
283
322
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
@@ -14,6 +14,7 @@ from typing import Any
|
|
|
14
14
|
SCHEMA = "maggie-deployment-runtime-parity.v1"
|
|
15
15
|
ENVIRONMENTS = {"development", "staging", "production"}
|
|
16
16
|
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$")
|
|
17
|
+
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def load(path: Path) -> dict[str, Any]:
|
|
@@ -42,6 +43,38 @@ def validate(payload: dict[str, Any]) -> list[str]:
|
|
|
42
43
|
if payload.get("passed") is not True:
|
|
43
44
|
errors.append("parity evidence is not marked passed")
|
|
44
45
|
|
|
46
|
+
release = payload.get("release")
|
|
47
|
+
if not isinstance(release, dict):
|
|
48
|
+
errors.append("release evidence must be an object")
|
|
49
|
+
release = {}
|
|
50
|
+
for field in ("activeRevision", "expectedRevision", "rollbackRevision"):
|
|
51
|
+
if not isinstance(release.get(field), str) or not SAFE_ID.fullmatch(release[field]):
|
|
52
|
+
errors.append(f"release.{field} must be a safe identifier")
|
|
53
|
+
if release.get("activeRevision") != release.get("expectedRevision"):
|
|
54
|
+
errors.append("active release revision does not match expected revision")
|
|
55
|
+
if release.get("rollbackRevision") == release.get("activeRevision"):
|
|
56
|
+
errors.append("rollback revision must differ from the active revision")
|
|
57
|
+
if release.get("environment") != payload.get("environment"):
|
|
58
|
+
errors.append("release environment does not match parity environment")
|
|
59
|
+
for field in ("runtimeConfigHash", "expectedConfigHash"):
|
|
60
|
+
if not isinstance(release.get(field), str) or not SHA256.fullmatch(release[field]):
|
|
61
|
+
errors.append(f"release.{field} must be a SHA-256 hex digest")
|
|
62
|
+
if release.get("runtimeConfigHash") != release.get("expectedConfigHash"):
|
|
63
|
+
errors.append("active runtime configuration does not match expected configuration")
|
|
64
|
+
for field in ("migrationVersion", "expectedMigrationVersion", "workerRevision"):
|
|
65
|
+
if not isinstance(release.get(field), str) or not SAFE_ID.fullmatch(release[field]):
|
|
66
|
+
errors.append(f"release.{field} must be a safe identifier")
|
|
67
|
+
if release.get("migrationVersion") != release.get("expectedMigrationVersion"):
|
|
68
|
+
errors.append("active migration version does not match expected migration version")
|
|
69
|
+
assets = release.get("assetVersions")
|
|
70
|
+
if not isinstance(assets, list) or not assets:
|
|
71
|
+
errors.append("release.assetVersions must contain at least one asset hash")
|
|
72
|
+
for index, asset in enumerate(assets if isinstance(assets, list) else []):
|
|
73
|
+
if not isinstance(asset, dict) or not SAFE_ID.fullmatch(str(asset.get("name") or "")):
|
|
74
|
+
errors.append(f"release.assetVersions[{index}].name must be a safe identifier")
|
|
75
|
+
if not isinstance(asset, dict) or not SHA256.fullmatch(str(asset.get("sha256") or "")):
|
|
76
|
+
errors.append(f"release.assetVersions[{index}].sha256 must be a SHA-256 hex digest")
|
|
77
|
+
|
|
45
78
|
worker = payload.get("worker")
|
|
46
79
|
if not isinstance(worker, dict):
|
|
47
80
|
errors.append("worker evidence must be an object")
|
|
@@ -58,6 +91,8 @@ def validate(payload: dict[str, Any]) -> list[str]:
|
|
|
58
91
|
errors.append("worker.config has missing requirements")
|
|
59
92
|
if config.get("passed") is not True:
|
|
60
93
|
errors.append("worker.config did not pass")
|
|
94
|
+
if worker.get("revision") != release.get("activeRevision"):
|
|
95
|
+
errors.append("worker revision does not match the active release")
|
|
61
96
|
|
|
62
97
|
migration = worker.get("migration")
|
|
63
98
|
privilege = migration.get("privilegeCheck") if isinstance(migration, dict) else None
|
|
@@ -84,6 +119,8 @@ def validate(payload: dict[str, Any]) -> list[str]:
|
|
|
84
119
|
errors.append(f"{prefix}.status must be a successful HTTP status")
|
|
85
120
|
if route.get("passed") is not True:
|
|
86
121
|
errors.append(f"{prefix} did not pass")
|
|
122
|
+
if route.get("releaseRevision") != release.get("activeRevision"):
|
|
123
|
+
errors.append(f"{prefix}.releaseRevision does not match the active release")
|
|
87
124
|
|
|
88
125
|
processes = worker.get("residentProcesses")
|
|
89
126
|
if not isinstance(processes, dict):
|
|
@@ -135,6 +172,11 @@ def parity(evidence: Path, output: Path) -> int:
|
|
|
135
172
|
"status": "passed" if not errors else "failed",
|
|
136
173
|
"passed": not errors,
|
|
137
174
|
"checks": {
|
|
175
|
+
"activeRelease": "validated",
|
|
176
|
+
"runtimeConfiguration": "validated",
|
|
177
|
+
"migrationVersion": "validated",
|
|
178
|
+
"assetVersions": "validated",
|
|
179
|
+
"rollbackTarget": "validated",
|
|
138
180
|
"workerConfig": "validated",
|
|
139
181
|
"migrationPrivileges": "validated",
|
|
140
182
|
"routeApis": "validated",
|
|
@@ -28,6 +28,10 @@ LOCAL_PATH_RE = re.compile(
|
|
|
28
28
|
)
|
|
29
29
|
VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
|
|
30
30
|
SAFE_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
|
31
|
+
SAFE_ROUTE_RE = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]{1,240}$")
|
|
32
|
+
SAFE_EVIDENCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/=-]{0,159}$")
|
|
33
|
+
MAX_ROUTE_IDS = 20
|
|
34
|
+
MAX_VALIDATION_EVIDENCE = 20
|
|
31
35
|
ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
|
|
32
36
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
33
37
|
|
|
@@ -107,6 +111,29 @@ def load_json(path: Path) -> dict:
|
|
|
107
111
|
return value
|
|
108
112
|
|
|
109
113
|
|
|
114
|
+
def safe_route_ids(values: object) -> list[str]:
|
|
115
|
+
"""Keep bounded route identifiers, never request bodies or local paths."""
|
|
116
|
+
if not isinstance(values, list):
|
|
117
|
+
return []
|
|
118
|
+
result = []
|
|
119
|
+
for value in values[:MAX_ROUTE_IDS]:
|
|
120
|
+
item = safe_text(value)
|
|
121
|
+
if SAFE_ROUTE_RE.fullmatch(item):
|
|
122
|
+
result.append(item)
|
|
123
|
+
return sorted(set(result))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def safe_validation_evidence(values: object) -> list[str]:
|
|
127
|
+
if not isinstance(values, list):
|
|
128
|
+
return []
|
|
129
|
+
result = []
|
|
130
|
+
for value in values[:MAX_VALIDATION_EVIDENCE]:
|
|
131
|
+
item = safe_text(value)
|
|
132
|
+
if SAFE_EVIDENCE_RE.fullmatch(item):
|
|
133
|
+
result.append(item)
|
|
134
|
+
return sorted(set(result))
|
|
135
|
+
|
|
136
|
+
|
|
110
137
|
def collect(args: argparse.Namespace) -> int:
|
|
111
138
|
project = Path(args.project).resolve()
|
|
112
139
|
report = load_json(Path(args.run_report)) if args.run_report else {}
|
|
@@ -132,6 +159,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
132
159
|
if priority not in {"low", "normal", "high", "critical"}:
|
|
133
160
|
raise ValueError("priority must be low, normal, high, or critical")
|
|
134
161
|
affected_cli = safe_text(getattr(args, "affected_cli", ""))
|
|
162
|
+
route_ids = safe_route_ids(getattr(args, "route_ids", []))
|
|
163
|
+
validation_evidence = safe_validation_evidence(getattr(args, "validation_evidence", []))
|
|
135
164
|
context = {"projectFingerprint": project_fingerprint(project)}
|
|
136
165
|
if args.allow_project_context and args.context_note:
|
|
137
166
|
context["note"] = safe_text(args.context_note)
|
|
@@ -156,6 +185,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
156
185
|
"validation": validation,
|
|
157
186
|
"priority": priority,
|
|
158
187
|
"affectedCli": affected_cli,
|
|
188
|
+
"routeIds": route_ids,
|
|
189
|
+
"validationEvidence": validation_evidence,
|
|
159
190
|
"attachments": [attachment(value) for value in args.screenshot],
|
|
160
191
|
"environment": {"os": platform.system().lower(), "python": platform.python_version()},
|
|
161
192
|
"privacy": {"secretsRedacted": True, "projectContextAllowed": bool(args.allow_project_context)},
|
|
@@ -264,6 +295,8 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
264
295
|
"phase": safe_text(manifest.get("phase")),
|
|
265
296
|
"priority": safe_text(manifest.get("priority") or "normal"),
|
|
266
297
|
"affected_cli": safe_text(manifest.get("affectedCli")),
|
|
298
|
+
"route_ids": safe_route_ids(manifest.get("routeIds", [])),
|
|
299
|
+
"validation_evidence": safe_validation_evidence(manifest.get("validationEvidence", [])),
|
|
267
300
|
}
|
|
268
301
|
paths = []
|
|
269
302
|
for index, item in enumerate(items):
|
|
@@ -292,14 +325,82 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
292
325
|
"batch_size": len(items),
|
|
293
326
|
"priority": item.get("priority") or shared["priority"],
|
|
294
327
|
"affected_cli": item.get("affectedCli") or shared["affected_cli"],
|
|
328
|
+
"route_ids": item.get("routeIds", shared["route_ids"]),
|
|
329
|
+
"validation_evidence": item.get("validationEvidence", shared["validation_evidence"]),
|
|
295
330
|
}
|
|
296
331
|
if not isinstance(values["reproduce"], list): values["reproduce"] = []
|
|
297
332
|
if not isinstance(values["screenshot"], list): values["screenshot"] = []
|
|
333
|
+
values["route_ids"] = safe_route_ids(values["route_ids"])
|
|
334
|
+
values["validation_evidence"] = safe_validation_evidence(values["validation_evidence"])
|
|
298
335
|
paths.append(collect(argparse.Namespace(**values)))
|
|
299
336
|
print(json.dumps({"batchId": batch_id, "count": len(paths), "status": "drafts-created"}, ensure_ascii=False))
|
|
300
337
|
return 0
|
|
301
338
|
|
|
302
339
|
|
|
340
|
+
def batch_review(args: argparse.Namespace) -> int:
|
|
341
|
+
"""Aggregate one bounded batch without exposing raw payloads or paths."""
|
|
342
|
+
project = Path(args.project).resolve()
|
|
343
|
+
batch_id = safe_text(args.batch_id)
|
|
344
|
+
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
345
|
+
raise ValueError("batch ID is invalid")
|
|
346
|
+
records = []
|
|
347
|
+
expected_sizes = []
|
|
348
|
+
for path in sorted((project / ".maggie" / "feedback").glob("*.json")):
|
|
349
|
+
try:
|
|
350
|
+
data = read_feedback(path)
|
|
351
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
352
|
+
continue
|
|
353
|
+
batch = data.get("batch") if isinstance(data.get("batch"), dict) else {}
|
|
354
|
+
if batch.get("batchId") != batch_id:
|
|
355
|
+
continue
|
|
356
|
+
if isinstance(batch.get("size"), int):
|
|
357
|
+
expected_sizes.append(batch["size"])
|
|
358
|
+
records.append({
|
|
359
|
+
"feedbackId": safe_text(data.get("feedbackId")),
|
|
360
|
+
"index": batch.get("index"),
|
|
361
|
+
"type": safe_text(data.get("type")),
|
|
362
|
+
"skill": safe_text(data.get("skill")),
|
|
363
|
+
"phase": safe_text(data.get("phase")),
|
|
364
|
+
"summary": safe_text(data.get("summary")),
|
|
365
|
+
"expected": safe_text(data.get("expected")),
|
|
366
|
+
"actual": safe_text(data.get("actual")),
|
|
367
|
+
"errorFingerprint": safe_text(data.get("errorFingerprint")),
|
|
368
|
+
"fixed": data.get("fixed") is True,
|
|
369
|
+
"resolution": safe_text(data.get("resolution")),
|
|
370
|
+
"validation": safe_text(data.get("validation")),
|
|
371
|
+
"routeIds": safe_route_ids(data.get("routeIds", [])),
|
|
372
|
+
"validationEvidence": safe_validation_evidence(data.get("validationEvidence", [])),
|
|
373
|
+
})
|
|
374
|
+
records.sort(key=lambda item: (item["index"] is None, item["index"] if isinstance(item["index"], int) else 0, item["feedbackId"]))
|
|
375
|
+
expected_count = max(expected_sizes, default=len(records))
|
|
376
|
+
seen_indexes = {item["index"] for item in records if isinstance(item["index"], int)}
|
|
377
|
+
duplicate_groups = {}
|
|
378
|
+
for item in records:
|
|
379
|
+
key = item["errorFingerprint"] or re.sub(r"\s+", " ", item["summary"].lower()).strip()
|
|
380
|
+
if key:
|
|
381
|
+
duplicate_groups.setdefault(key, []).append(item["feedbackId"])
|
|
382
|
+
duplicates = [{"key": safe_text(key), "feedbackIds": ids} for key, ids in duplicate_groups.items() if len(ids) > 1]
|
|
383
|
+
output = Path(args.output) if args.output else project / ".maggie" / "feedback" / "batches" / f"{batch_id}-review.json"
|
|
384
|
+
if not output.is_absolute(): output = project / output
|
|
385
|
+
report = {
|
|
386
|
+
"schemaVersion": "maggie-feedback-batch-review.v1",
|
|
387
|
+
"batchId": batch_id,
|
|
388
|
+
"status": "passed" if records and not duplicates and len(records) == expected_count else "needs-review",
|
|
389
|
+
"expectedCount": expected_count,
|
|
390
|
+
"draftCount": len(records),
|
|
391
|
+
"missingIndexes": sorted(set(range(expected_count)) - seen_indexes),
|
|
392
|
+
"skills": sorted({item["skill"] for item in records if item["skill"]}),
|
|
393
|
+
"phases": sorted({item["phase"] for item in records if item["phase"]}),
|
|
394
|
+
"observations": records,
|
|
395
|
+
"duplicates": duplicates,
|
|
396
|
+
"privacy": {"rawPayloadsIncluded": False, "localPathsIncluded": False, "secretsRedacted": True},
|
|
397
|
+
}
|
|
398
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
399
|
+
output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
400
|
+
print(json.dumps({"status": report["status"], "batchId": batch_id, "draftCount": len(records), "expectedCount": expected_count, "duplicates": len(duplicates), "report": str(output.resolve())}, ensure_ascii=False))
|
|
401
|
+
return 0 if report["status"] == "passed" else 1
|
|
402
|
+
|
|
403
|
+
|
|
303
404
|
def main() -> int:
|
|
304
405
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
305
406
|
parser.add_argument("--project", default=".")
|
|
@@ -329,9 +430,15 @@ def main() -> int:
|
|
|
329
430
|
collect_parser.add_argument("--batch-size", type=int)
|
|
330
431
|
collect_parser.add_argument("--priority", choices=("low", "normal", "high", "critical"), default="normal")
|
|
331
432
|
collect_parser.add_argument("--affected-cli", default="")
|
|
433
|
+
collect_parser.add_argument("--route-id", dest="route_ids", action="append", default=[])
|
|
434
|
+
collect_parser.add_argument("--validation-evidence", dest="validation_evidence", action="append", default=[])
|
|
332
435
|
batch_parser = sub.add_parser("batch")
|
|
333
436
|
batch_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
334
437
|
batch_parser.add_argument("--batch-file", required=True)
|
|
438
|
+
review_parser = sub.add_parser("batch-review")
|
|
439
|
+
review_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
440
|
+
review_parser.add_argument("--batch-id", required=True)
|
|
441
|
+
review_parser.add_argument("--output")
|
|
335
442
|
preview_parser = sub.add_parser("preview")
|
|
336
443
|
preview_parser.add_argument("feedback")
|
|
337
444
|
preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
@@ -344,6 +451,7 @@ def main() -> int:
|
|
|
344
451
|
try:
|
|
345
452
|
if args.command == "collect": return collect(args)
|
|
346
453
|
if args.command == "batch": return collect_batch(args)
|
|
454
|
+
if args.command == "batch-review": return batch_review(args)
|
|
347
455
|
if args.command == "preview": return preview(args)
|
|
348
456
|
if args.command == "submit": return submit(args)
|
|
349
457
|
return list_feedback(args)
|
|
@@ -216,7 +216,15 @@ def plan_job(args: argparse.Namespace) -> int:
|
|
|
216
216
|
"translationGroupId": identity.get("translationGroupId", f"tg-{content_id}"),
|
|
217
217
|
"sourceRevision": args.source_revision or (source_artifact or {}).get("sourceRevision") or content.get("sourceRevision", "unknown"),
|
|
218
218
|
"status": "draft",
|
|
219
|
-
"translation": {
|
|
219
|
+
"translation": {
|
|
220
|
+
"translationStatus": "draft", "isIndexable": False,
|
|
221
|
+
"provenance": {
|
|
222
|
+
"operation": args.operation, "sourceRevision": args.source_revision or (source_artifact or {}).get("sourceRevision") or content.get("sourceRevision", "unknown"),
|
|
223
|
+
"sourceLanguage": args.source_lang, "targetLanguage": args.target_lang,
|
|
224
|
+
"targetLocale": args.locale, "market": args.market,
|
|
225
|
+
"evidenceIds": [f"content:{content_id}"], "createdAt": now(),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
220
228
|
"createdAt": now(),
|
|
221
229
|
}
|
|
222
230
|
if source_artifact:
|
|
@@ -246,6 +254,15 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
246
254
|
errors.append("targetLocale must be a supported tag matching targetLanguage")
|
|
247
255
|
translation = job.get("translation", {})
|
|
248
256
|
generation = job.get("generationContract", {})
|
|
257
|
+
provenance = translation.get("provenance", {}) if isinstance(translation, dict) else {}
|
|
258
|
+
if not isinstance(provenance, dict):
|
|
259
|
+
errors.append("translation.provenance must be an object")
|
|
260
|
+
provenance = {}
|
|
261
|
+
for field in ("sourceLanguage", "targetLanguage", "targetLocale", "market"):
|
|
262
|
+
if provenance.get(field) != job.get({"sourceLanguage": "sourceLanguage", "targetLanguage": "targetLanguage", "targetLocale": "targetLocale", "market": "market"}[field]):
|
|
263
|
+
errors.append(f"translation.provenance.{field} does not match the job")
|
|
264
|
+
if provenance.get("sourceRevision") != job.get("sourceRevision"):
|
|
265
|
+
errors.append("translation.provenance.sourceRevision does not match the job")
|
|
249
266
|
if generation.get("mode") and generation.get("mode") != job.get("operation"):
|
|
250
267
|
errors.append("generationContract mode must match operation")
|
|
251
268
|
if job.get("operation") == "rewrite" and not generation.get("allowStructuralRewrite"):
|