@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.
Files changed (33) hide show
  1. package/README.md +50 -4
  2. package/README.zh-TW.md +4 -2
  3. package/bin/maggie.js +17 -5
  4. package/bundled-contracts/maggie-deployment/runtime-parity-v1.schema.json +20 -2
  5. package/bundled-contracts/maggie-seo/model-policy-v1.schema.json +17 -0
  6. package/bundled-contracts/maggie-service-booking/resolver-v1.schema.json +17 -0
  7. package/bundled-contracts/maggie-service-booking/worker-health-v1.schema.json +17 -0
  8. package/bundled-contracts/maggiedash/README.md +5 -0
  9. package/bundled-contracts/maggiedash/migration-preflight-v1.schema.json +16 -0
  10. package/bundled-references/blog-translation-ingestion.md +7 -0
  11. package/bundled-references/maggiedash-integration.md +19 -0
  12. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +21 -0
  13. package/bundled-skills/maggie-dash/SKILL.md +34 -0
  14. package/bundled-skills/maggie-deployment/SKILL.md +22 -7
  15. package/bundled-skills/maggie-deployment/references/vps.md +12 -2
  16. package/bundled-skills/maggie-feedback/SKILL.md +13 -3
  17. package/bundled-skills/maggie-marketplace/SKILL.md +20 -1
  18. package/bundled-skills/maggie-seo-geo/SKILL.md +11 -0
  19. package/bundled-skills/maggie-service-booking/SKILL.md +19 -0
  20. package/bundled-tools/clis/maggie.py +29 -1
  21. package/bundled-tools/clis/maggie_dash.py +108 -0
  22. package/bundled-tools/clis/maggie_deployment.py +43 -4
  23. package/bundled-tools/clis/maggie_deployment_parity.py +42 -0
  24. package/bundled-tools/clis/maggie_feedback.py +108 -0
  25. package/bundled-tools/clis/maggie_localization.py +18 -1
  26. package/bundled-tools/clis/maggie_marketplace.py +58 -4
  27. package/bundled-tools/clis/maggie_migration.py +43 -0
  28. package/bundled-tools/clis/maggie_service_booking.py +84 -1
  29. package/bundled-tools/integrations/maggie-api-pull.md +8 -4
  30. package/bundled-tools/runtime/maggie_dash_store.py +20 -0
  31. package/package.json +1 -1
  32. package/references/blog-translation-ingestion.md +7 -0
  33. 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 = {key: value[key] for key in allowed if key in value and isinstance(value[key], (str, int, float, bool, type(None)))}
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
- evidence = json.loads(rollback_evidence.read_text(encoding="utf-8"))
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=4321)
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": {"translationStatus": "draft", "isIndexable": False},
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"):