@topy-ai/maggie 0.7.44 → 0.7.46
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-zh-TW.md +29 -6
- package/README.md +86 -12
- package/bin/maggie.js +207 -58
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +76 -0
- package/bundled-contracts/google-integrations/external-write-readback-v1.schema.json +36 -0
- package/bundled-contracts/maggie-deployment/deployer-delegation-v1.schema.json +20 -0
- package/bundled-contracts/maggie-deployment/release-profile-v1.schema.json +34 -0
- package/bundled-contracts/maggie-design/browser-capability-v1.schema.json +33 -0
- package/bundled-contracts/maggie-feedback/evidence-bundle-v1.schema.json +38 -0
- package/bundled-references/browser-inspection.md +17 -0
- package/bundled-references/google-integrations-runbook.md +8 -1
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +7 -7
- package/bundled-skills/maggie-clone/SKILL.md +21 -14
- package/bundled-skills/maggie-clone-to-template/SKILL.md +11 -11
- package/bundled-skills/maggie-clone-to-template/references/workflow.md +2 -2
- package/bundled-skills/maggie-content-localization/SKILL.md +10 -10
- package/bundled-skills/maggie-deployment/SKILL.md +57 -23
- package/bundled-skills/maggie-deployment/references/vps.md +4 -4
- package/bundled-skills/maggie-design/SKILL.md +12 -12
- package/bundled-skills/maggie-feedback/SKILL.md +23 -0
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -12
- package/bundled-skills/maggie-ops/SKILL.md +28 -6
- package/bundled-skills/maggie-project-context/SKILL.md +1 -1
- package/bundled-skills/maggie-seo-geo/SKILL.md +6 -3
- package/bundled-skills/maggie-service-booking/SKILL.md +24 -24
- package/bundled-skills/maggie-social-share/SKILL.md +2 -2
- package/bundled-skills/maggie-template/SKILL.md +5 -5
- package/bundled-tools/clis/maggie_analytics.py +8 -0
- package/bundled-tools/clis/maggie_browser_audit.py +13 -2
- package/bundled-tools/clis/maggie_deployment.py +167 -6
- package/bundled-tools/clis/maggie_feedback.py +115 -1
- package/bundled-tools/clis/maggie_ops.py +33 -0
- package/bundled-tools/clis/maggie_release.py +124 -19
- package/bundled-tools/runtime/browser_capability.py +143 -0
- package/bundled-tools/runtime/external_write.py +122 -0
- package/bundled-tools/runtime/google_capabilities.py +37 -5
- package/package.json +1 -1
- package/references/browser-inspection.md +17 -0
- package/references/google-integrations-runbook.md +8 -1
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
|
+
import shlex
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
from datetime import datetime, timezone
|
|
@@ -181,7 +182,103 @@ def worker_secret_preflight_main(argv: list[str]) -> int:
|
|
|
181
182
|
return 0 if result["passed"] else 1
|
|
182
183
|
|
|
183
184
|
|
|
184
|
-
def
|
|
185
|
+
def deployer_delegation_preflight(
|
|
186
|
+
host: str,
|
|
187
|
+
user: str,
|
|
188
|
+
release_root: str,
|
|
189
|
+
service: str,
|
|
190
|
+
ssh_command: list[str] | None = None,
|
|
191
|
+
) -> dict:
|
|
192
|
+
"""Read-only SSH check for release-directory and systemd delegation."""
|
|
193
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,253}", host):
|
|
194
|
+
raise ValueError("deployer host must be a hostname, address, or IPv6-safe token")
|
|
195
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", user):
|
|
196
|
+
raise ValueError("deployer user must be a safe local account name")
|
|
197
|
+
if not release_root.startswith("/") or "\n" in release_root or "\r" in release_root:
|
|
198
|
+
raise ValueError("release root must be an absolute path without newlines")
|
|
199
|
+
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
|
|
200
|
+
raise ValueError("service must contain only safe systemd name characters")
|
|
201
|
+
|
|
202
|
+
root = release_root.rstrip("/") or "/"
|
|
203
|
+
releases = f"{root}/releases" if root != "/" else "/releases"
|
|
204
|
+
remote_script = "\n".join((
|
|
205
|
+
"set -eu",
|
|
206
|
+
f"test -d {shlex.quote(root)} && test -w {shlex.quote(root)}",
|
|
207
|
+
f"test -d {shlex.quote(releases)} && test -w {shlex.quote(releases)}",
|
|
208
|
+
f"sudo -n -l /bin/systemctl restart {shlex.quote(service)} >/dev/null 2>&1",
|
|
209
|
+
f"sudo -n -l /bin/systemctl is-active {shlex.quote(service)} >/dev/null 2>&1",
|
|
210
|
+
)) + "\n"
|
|
211
|
+
command = list(ssh_command or ["ssh"])
|
|
212
|
+
command.extend(["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", f"{user}@{host}", "sh", "-s"])
|
|
213
|
+
try:
|
|
214
|
+
completed = subprocess.run(command, input=remote_script, capture_output=True, text=True, check=False, timeout=20)
|
|
215
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
216
|
+
return {
|
|
217
|
+
"schemaVersion": "maggie-deployment-delegation.v1",
|
|
218
|
+
"host": host,
|
|
219
|
+
"user": user,
|
|
220
|
+
"releaseRoot": root,
|
|
221
|
+
"service": service,
|
|
222
|
+
"state": "transient",
|
|
223
|
+
"passed": False,
|
|
224
|
+
"checks": {},
|
|
225
|
+
"errors": [f"SSH delegation check could not start: {type(error).__name__}"],
|
|
226
|
+
"mutation": "not executed",
|
|
227
|
+
}
|
|
228
|
+
stderr = (completed.stderr or "").lower()
|
|
229
|
+
transient = completed.returncode in {124, 255} or any(marker in stderr for marker in (
|
|
230
|
+
"timed out", "connection refused", "connection reset", "no route", "could not resolve", "temporary failure",
|
|
231
|
+
))
|
|
232
|
+
state = "passed" if completed.returncode == 0 else "transient" if transient else "failed"
|
|
233
|
+
return {
|
|
234
|
+
"schemaVersion": "maggie-deployment-delegation.v1",
|
|
235
|
+
"host": host,
|
|
236
|
+
"user": user,
|
|
237
|
+
"releaseRoot": root,
|
|
238
|
+
"service": service,
|
|
239
|
+
"state": state,
|
|
240
|
+
"passed": completed.returncode == 0,
|
|
241
|
+
"checks": {
|
|
242
|
+
"sshConnection": completed.returncode == 0,
|
|
243
|
+
"releaseRootWritable": completed.returncode == 0,
|
|
244
|
+
"releasesDirectoryWritable": completed.returncode == 0,
|
|
245
|
+
"sudoSystemctlRestart": completed.returncode == 0,
|
|
246
|
+
"sudoSystemctlStatus": completed.returncode == 0,
|
|
247
|
+
},
|
|
248
|
+
"errors": [] if completed.returncode == 0 else [
|
|
249
|
+
"deploy-user delegation check is transient; retry after SSH/host readiness" if transient
|
|
250
|
+
else "deploy-user cannot write the release directory or use the exact systemd allowlist"
|
|
251
|
+
],
|
|
252
|
+
"mutation": "not executed",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def validate_deployer_delegation(value: object, expected_root: str | None = None, expected_service: str | None = None) -> dict:
|
|
257
|
+
if not isinstance(value, dict):
|
|
258
|
+
return {"passed": False, "state": "failed", "errors": ["delegation evidence must be an object"]}
|
|
259
|
+
errors: list[str] = []
|
|
260
|
+
if value.get("schemaVersion") != "maggie-deployment-delegation.v1":
|
|
261
|
+
errors.append("delegation evidence schemaVersion is unsupported")
|
|
262
|
+
if value.get("state") != "passed" or value.get("passed") is not True:
|
|
263
|
+
errors.append("deploy-user delegation evidence is not passed")
|
|
264
|
+
if expected_root and value.get("releaseRoot") != expected_root.rstrip("/"):
|
|
265
|
+
errors.append("delegation releaseRoot does not match the deployment plan")
|
|
266
|
+
if expected_service and value.get("service") != expected_service:
|
|
267
|
+
errors.append("delegation service does not match the deployment plan")
|
|
268
|
+
checks = value.get("checks") if isinstance(value.get("checks"), dict) else {}
|
|
269
|
+
for name in ("sshConnection", "releaseRootWritable", "releasesDirectoryWritable", "sudoSystemctlRestart", "sudoSystemctlStatus"):
|
|
270
|
+
if checks.get(name) is not True:
|
|
271
|
+
errors.append(f"delegation check is not passed: {name}")
|
|
272
|
+
return {"passed": not errors, "state": "passed" if not errors else "failed", "errors": errors}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def preflight(
|
|
276
|
+
project: Path,
|
|
277
|
+
target: str,
|
|
278
|
+
environment: str,
|
|
279
|
+
require_deployer: bool = False,
|
|
280
|
+
delegation_evidence: Path | None = None,
|
|
281
|
+
) -> dict:
|
|
185
282
|
package_path = project / "package.json"
|
|
186
283
|
package = json.loads(package_path.read_text(encoding="utf-8")) if package_path.exists() else {}
|
|
187
284
|
scripts = package.get("scripts") or {}
|
|
@@ -240,10 +337,24 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
240
337
|
data_required = bool(migration.get("dataDependencies"))
|
|
241
338
|
data_checkpoint = validate_data_checkpoint(data_path) if data_path.exists() else {"passed": not data_required, "declared": False, "errors": ["data-release.json is required for a data-dependent release"] if data_required else []}
|
|
242
339
|
retention_ok = bool(plan.get("retention", {}).get("keep") in (2, 3, 4, 5) and plan.get("retention", {}).get("prune") and plan.get("retention", {}).get("preserve"))
|
|
243
|
-
|
|
340
|
+
delegation = {"passed": True, "state": "not-required", "errors": []}
|
|
341
|
+
delegation_path = delegation_evidence or project / ".maggie" / "deployment" / "deployer-delegation.json"
|
|
342
|
+
if require_deployer:
|
|
343
|
+
try:
|
|
344
|
+
delegation_value = json.loads(delegation_path.read_text(encoding="utf-8"))
|
|
345
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
346
|
+
delegation_value = {}
|
|
347
|
+
delegation = {"passed": False, "state": "inconclusive", "errors": [f"delegation evidence is unavailable: {error}"]}
|
|
348
|
+
else:
|
|
349
|
+
delegation = validate_deployer_delegation(delegation_value, plan.get("release_root"), plan.get("service"))
|
|
350
|
+
delegation["path"] = str(delegation_path)
|
|
351
|
+
result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
|
|
352
|
+
result["delegation"] = delegation
|
|
244
353
|
result["vps"] = {"runtime":"Node.js standalone","reverseProxy":"Nginx","processManager":"systemd","releaseLayout":"/var/www/<site>/releases/<release> + current symlink","dns":"Cloudflare DNS or registrar DNS; verify apex and www separately","remoteCommands":["npm ci","npm run build","systemctl restart <service>","systemctl is-active <service>","curl -fsS https://<domain>/robots.txt","curl -fsS https://<domain>/sitemap.xml"],"handoverFields":["host","sshUser","domain","service","release","previousRelease","nginxConfig","migrationVersion","verification","rollback"]}
|
|
245
354
|
result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
|
|
246
355
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
356
|
+
if require_deployer:
|
|
357
|
+
result["blocking_checks"].append("deployer_delegation")
|
|
247
358
|
else:
|
|
248
359
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
|
|
249
360
|
return result
|
|
@@ -471,9 +582,38 @@ if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude";
|
|
|
471
582
|
|
|
472
583
|
ln -sfn "$RELEASE" "$CURRENT"
|
|
473
584
|
sudo -n /bin/systemctl restart "{service}"
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
585
|
+
|
|
586
|
+
# A restarted service can be healthy at the origin before Nginx/Cloudflare is
|
|
587
|
+
# ready. Retry only bounded transient states; fail immediately on permanent
|
|
588
|
+
# HTTP responses so an outage is not hidden as a startup delay.
|
|
589
|
+
wait_for_active() {{
|
|
590
|
+
local attempt
|
|
591
|
+
for attempt in $(seq 1 12); do
|
|
592
|
+
if sudo -n /bin/systemctl is-active --quiet "{service}"; then return 0; fi
|
|
593
|
+
if [ "$attempt" -lt 12 ]; then sleep 2; fi
|
|
594
|
+
done
|
|
595
|
+
echo "permanent service readiness failure: {service}" >&2
|
|
596
|
+
return 1
|
|
597
|
+
}}
|
|
598
|
+
wait_for_http() {{
|
|
599
|
+
local url="$1" attempt status
|
|
600
|
+
for attempt in $(seq 1 12); do
|
|
601
|
+
status="$(curl --silent --show-error --output /dev/null --write-out '%{{http_code}}' "$url" 2>/dev/null || printf '000')"
|
|
602
|
+
case "$status" in
|
|
603
|
+
2??|3??) return 0 ;;
|
|
604
|
+
000|502|503|504)
|
|
605
|
+
if [ "$attempt" -lt 12 ]; then sleep 2; continue; fi
|
|
606
|
+
echo "transient readiness exhausted: $url returned $status" >&2
|
|
607
|
+
return 1 ;;
|
|
608
|
+
*)
|
|
609
|
+
echo "permanent readiness failure: $url returned $status" >&2
|
|
610
|
+
return 1 ;;
|
|
611
|
+
esac
|
|
612
|
+
done
|
|
613
|
+
}}
|
|
614
|
+
wait_for_active
|
|
615
|
+
wait_for_http "https://{domain}/robots.txt"
|
|
616
|
+
wait_for_http "https://{domain}/sitemap.xml"
|
|
477
617
|
|
|
478
618
|
# Keep current plus one rollback candidate, and prune only after health checks.
|
|
479
619
|
mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
|
|
@@ -507,6 +647,10 @@ def main() -> int:
|
|
|
507
647
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
508
648
|
parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
|
|
509
649
|
parser.add_argument("--deployer-user", default="maggie-deploy", help="dedicated least-privilege SSH/deploy account")
|
|
650
|
+
parser.add_argument("--deployer-host", help="SSH host for --verify-deployer")
|
|
651
|
+
parser.add_argument("--verify-deployer", action="store_true", help="run a read-only SSH delegation check before writing VPS artifacts")
|
|
652
|
+
parser.add_argument("--require-deployer", action="store_true", help="require passed delegation evidence during deployment preflight")
|
|
653
|
+
parser.add_argument("--deployer-evidence", help="delegation evidence JSON; defaults to .maggie/deployment/deployer-delegation.json")
|
|
510
654
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
511
655
|
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
512
656
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
@@ -521,6 +665,17 @@ def main() -> int:
|
|
|
521
665
|
parser.add_argument("--has-auth", action="store_true")
|
|
522
666
|
args = parser.parse_args()
|
|
523
667
|
try:
|
|
668
|
+
if args.verify_deployer:
|
|
669
|
+
if not args.deployer_host:
|
|
670
|
+
parser.error("--deployer-host is required with --verify-deployer")
|
|
671
|
+
delegation = deployer_delegation_preflight(args.deployer_host, args.deployer_user, args.release_root, args.service)
|
|
672
|
+
if args.output:
|
|
673
|
+
output = Path(args.output).resolve()
|
|
674
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
675
|
+
output.write_text(json.dumps(delegation, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
676
|
+
print(json.dumps(delegation, indent=2, ensure_ascii=False))
|
|
677
|
+
if not delegation["passed"]:
|
|
678
|
+
return 1
|
|
524
679
|
if args.vps_plan:
|
|
525
680
|
if not args.domain:
|
|
526
681
|
parser.error("--domain is required with --vps-plan")
|
|
@@ -549,7 +704,13 @@ def main() -> int:
|
|
|
549
704
|
result = verify_infrastructure(Path(args.units_dir).resolve() if args.units_dir else None, args.service, args.timer)
|
|
550
705
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
551
706
|
return 0 if result["passed"] else 1
|
|
552
|
-
result = preflight(
|
|
707
|
+
result = preflight(
|
|
708
|
+
Path(args.project).resolve(),
|
|
709
|
+
args.target,
|
|
710
|
+
args.environment,
|
|
711
|
+
args.require_deployer,
|
|
712
|
+
Path(args.deployer_evidence).expanduser().resolve() if args.deployer_evidence else None,
|
|
713
|
+
)
|
|
553
714
|
if args.output:
|
|
554
715
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
555
716
|
Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -30,8 +30,15 @@ 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
31
|
SAFE_ROUTE_RE = re.compile(r"^/[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]{1,240}$")
|
|
32
32
|
SAFE_EVIDENCE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/=-]{0,159}$")
|
|
33
|
+
SAFE_FEEDBACK_ID_RE = re.compile(r"^fb-[A-Za-z0-9_-]{1,159}$")
|
|
33
34
|
MAX_ROUTE_IDS = 20
|
|
34
35
|
MAX_VALIDATION_EVIDENCE = 20
|
|
36
|
+
MAX_EVIDENCE_REFERENCES = 20
|
|
37
|
+
MAX_RELATIONSHIPS = 20
|
|
38
|
+
MAX_GRAPH_NODES = 200
|
|
39
|
+
MAX_GRAPH_EDGES = 300
|
|
40
|
+
EVIDENCE_KINDS = {"test", "report", "screenshot", "command", "deployment", "issue", "commit", "artifact"}
|
|
41
|
+
RELATION_KINDS = {"related", "same-run", "same-fingerprint", "duplicates", "validates", "remediates", "caused-by"}
|
|
35
42
|
ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
|
|
36
43
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
37
44
|
|
|
@@ -134,6 +141,64 @@ def safe_validation_evidence(values: object) -> list[str]:
|
|
|
134
141
|
return sorted(set(result))
|
|
135
142
|
|
|
136
143
|
|
|
144
|
+
def safe_evidence_references(values: object) -> list[dict]:
|
|
145
|
+
"""Normalize bounded evidence pointers without copying artifacts or payloads."""
|
|
146
|
+
if not isinstance(values, list):
|
|
147
|
+
return []
|
|
148
|
+
result = []
|
|
149
|
+
for value in values[:MAX_EVIDENCE_REFERENCES]:
|
|
150
|
+
if isinstance(value, str):
|
|
151
|
+
if ":" not in value:
|
|
152
|
+
continue
|
|
153
|
+
kind, reference = value.split(":", 1)
|
|
154
|
+
status = "observed"
|
|
155
|
+
elif isinstance(value, dict):
|
|
156
|
+
kind = value.get("kind", "")
|
|
157
|
+
reference = value.get("ref", "")
|
|
158
|
+
status = value.get("status", "observed")
|
|
159
|
+
else:
|
|
160
|
+
continue
|
|
161
|
+
kind = safe_text(kind).lower()
|
|
162
|
+
reference = safe_text(reference)
|
|
163
|
+
status = safe_text(status).lower()
|
|
164
|
+
if kind not in EVIDENCE_KINDS or not SAFE_EVIDENCE_RE.fullmatch(reference):
|
|
165
|
+
continue
|
|
166
|
+
if status not in {"passed", "failed", "observed", "not-run", "unknown"}:
|
|
167
|
+
status = "unknown"
|
|
168
|
+
result.append({"kind": kind, "ref": reference, "status": status})
|
|
169
|
+
return sorted({(item["kind"], item["ref"], item["status"]): item for item in result}.values(),
|
|
170
|
+
key=lambda item: (item["kind"], item["ref"], item["status"]))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def safe_relationships(values: object) -> list[dict]:
|
|
174
|
+
"""Normalize links to other safe IDs; never accept URLs or arbitrary payloads."""
|
|
175
|
+
if not isinstance(values, list):
|
|
176
|
+
return []
|
|
177
|
+
result = []
|
|
178
|
+
for value in values[:MAX_RELATIONSHIPS]:
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
if "=" not in value:
|
|
181
|
+
continue
|
|
182
|
+
kind, target = value.split("=", 1)
|
|
183
|
+
confidence = "medium"
|
|
184
|
+
elif isinstance(value, dict):
|
|
185
|
+
kind = value.get("kind", "")
|
|
186
|
+
target = value.get("target", "")
|
|
187
|
+
confidence = value.get("confidence", "medium")
|
|
188
|
+
else:
|
|
189
|
+
continue
|
|
190
|
+
kind = safe_text(kind).lower()
|
|
191
|
+
target = safe_text(target)
|
|
192
|
+
confidence = safe_text(confidence).lower()
|
|
193
|
+
if kind not in RELATION_KINDS or not SAFE_EVIDENCE_RE.fullmatch(target):
|
|
194
|
+
continue
|
|
195
|
+
if confidence not in {"low", "medium", "high"}:
|
|
196
|
+
confidence = "medium"
|
|
197
|
+
result.append({"kind": kind, "target": target, "confidence": confidence})
|
|
198
|
+
return sorted({(item["kind"], item["target"], item["confidence"]): item for item in result}.values(),
|
|
199
|
+
key=lambda item: (item["kind"], item["target"], item["confidence"]))
|
|
200
|
+
|
|
201
|
+
|
|
137
202
|
def collect(args: argparse.Namespace) -> int:
|
|
138
203
|
project = Path(args.project).resolve()
|
|
139
204
|
report = load_json(Path(args.run_report)) if args.run_report else {}
|
|
@@ -171,6 +236,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
171
236
|
affected_cli = safe_text(getattr(args, "affected_cli", ""))
|
|
172
237
|
route_ids = safe_route_ids(getattr(args, "route_ids", []))
|
|
173
238
|
validation_evidence = safe_validation_evidence(getattr(args, "validation_evidence", []))
|
|
239
|
+
evidence = safe_evidence_references(getattr(args, "evidence", []))
|
|
240
|
+
relationships = safe_relationships(getattr(args, "relationships", []))
|
|
174
241
|
context = {"projectFingerprint": project_fingerprint(project)}
|
|
175
242
|
if args.allow_project_context and args.context_note:
|
|
176
243
|
context["note"] = safe_text(args.context_note)
|
|
@@ -197,6 +264,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
197
264
|
"affectedCli": affected_cli,
|
|
198
265
|
"routeIds": route_ids,
|
|
199
266
|
"validationEvidence": validation_evidence,
|
|
267
|
+
"evidence": evidence,
|
|
268
|
+
"relationships": relationships,
|
|
200
269
|
"attachments": [attachment(value) for value in args.screenshot],
|
|
201
270
|
"environment": {"os": platform.system().lower(), "python": platform.python_version()},
|
|
202
271
|
"privacy": {"secretsRedacted": True, "projectContextAllowed": bool(args.allow_project_context)},
|
|
@@ -226,7 +295,7 @@ def to_markdown(data: dict) -> str:
|
|
|
226
295
|
lines += ["", "## Report", "", f"**Summary:** {data.get('summary') or 'Not provided'}", "", f"**Expected:** {data.get('expected') or 'Not provided'}", "", f"**Actual:** {data.get('actual') or 'Not provided'}", ""]
|
|
227
296
|
if data.get("stepsToReproduce"):
|
|
228
297
|
lines += ["**Steps to reproduce:**", ""] + [f"{index}. {step}" for index, step in enumerate(data["stepsToReproduce"], 1)] + [""]
|
|
229
|
-
lines += [f"**Fixed:** {'yes' if data.get('fixed') else 'no'}", f"**Resolution:** {data.get('resolution') or 'Not provided'}", f"**Validation:** {data.get('validation') or 'Not provided'}", "", "## Privacy", "", f"- Project context allowed: `{bool(data.get('privacy', {}).get('projectContextAllowed'))}`", f"- Attachments: `{len(data.get('attachments', []))}` metadata-only reference(s)", ""]
|
|
298
|
+
lines += [f"**Fixed:** {'yes' if data.get('fixed') else 'no'}", f"**Resolution:** {data.get('resolution') or 'Not provided'}", f"**Validation:** {data.get('validation') or 'Not provided'}", f"**Evidence references:** `{len(safe_evidence_references(data.get('evidence', [])))}`", f"**Relationships:** `{len(safe_relationships(data.get('relationships', [])))}`", "", "## Privacy", "", f"- Project context allowed: `{bool(data.get('privacy', {}).get('projectContextAllowed'))}`", f"- Attachments: `{len(data.get('attachments', []))}` metadata-only reference(s)", ""]
|
|
230
299
|
return "\n".join(lines)
|
|
231
300
|
|
|
232
301
|
|
|
@@ -307,6 +376,8 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
307
376
|
"affected_cli": safe_text(manifest.get("affectedCli")),
|
|
308
377
|
"route_ids": safe_route_ids(manifest.get("routeIds", [])),
|
|
309
378
|
"validation_evidence": safe_validation_evidence(manifest.get("validationEvidence", [])),
|
|
379
|
+
"evidence": safe_evidence_references(manifest.get("evidence", [])),
|
|
380
|
+
"relationships": safe_relationships(manifest.get("relationships", [])),
|
|
310
381
|
}
|
|
311
382
|
paths = []
|
|
312
383
|
for index, item in enumerate(items):
|
|
@@ -337,11 +408,15 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
337
408
|
"affected_cli": item.get("affectedCli") or shared["affected_cli"],
|
|
338
409
|
"route_ids": item.get("routeIds", shared["route_ids"]),
|
|
339
410
|
"validation_evidence": item.get("validationEvidence", shared["validation_evidence"]),
|
|
411
|
+
"evidence": item.get("evidence", shared["evidence"]),
|
|
412
|
+
"relationships": item.get("relationships", shared["relationships"]),
|
|
340
413
|
}
|
|
341
414
|
if not isinstance(values["reproduce"], list): values["reproduce"] = []
|
|
342
415
|
if not isinstance(values["screenshot"], list): values["screenshot"] = []
|
|
343
416
|
values["route_ids"] = safe_route_ids(values["route_ids"])
|
|
344
417
|
values["validation_evidence"] = safe_validation_evidence(values["validation_evidence"])
|
|
418
|
+
values["evidence"] = safe_evidence_references(values["evidence"])
|
|
419
|
+
values["relationships"] = safe_relationships(values["relationships"])
|
|
345
420
|
paths.append(collect(argparse.Namespace(**values)))
|
|
346
421
|
print(json.dumps({"batchId": batch_id, "count": len(paths), "status": "drafts-created"}, ensure_ascii=False))
|
|
347
422
|
return 0
|
|
@@ -380,6 +455,8 @@ def batch_review(args: argparse.Namespace) -> int:
|
|
|
380
455
|
"validation": safe_text(data.get("validation")),
|
|
381
456
|
"routeIds": safe_route_ids(data.get("routeIds", [])),
|
|
382
457
|
"validationEvidence": safe_validation_evidence(data.get("validationEvidence", [])),
|
|
458
|
+
"evidence": safe_evidence_references(data.get("evidence", [])),
|
|
459
|
+
"relationships": safe_relationships(data.get("relationships", [])),
|
|
383
460
|
})
|
|
384
461
|
records.sort(key=lambda item: (item["index"] is None, item["index"] if isinstance(item["index"], int) else 0, item["feedbackId"]))
|
|
385
462
|
expected_count = max(expected_sizes, default=len(records))
|
|
@@ -390,6 +467,39 @@ def batch_review(args: argparse.Namespace) -> int:
|
|
|
390
467
|
if key:
|
|
391
468
|
duplicate_groups.setdefault(key, []).append(item["feedbackId"])
|
|
392
469
|
duplicates = [{"key": safe_text(key), "feedbackIds": ids} for key, ids in duplicate_groups.items() if len(ids) > 1]
|
|
470
|
+
graph_nodes = [{"id": item["feedbackId"], "kind": "feedback"} for item in records]
|
|
471
|
+
graph_edges = []
|
|
472
|
+
evidence_nodes = {}
|
|
473
|
+
for item in records:
|
|
474
|
+
for evidence in item["evidence"]:
|
|
475
|
+
evidence_key = f"{evidence['kind']}:{evidence['ref']}"
|
|
476
|
+
evidence_id = "evidence-" + hashlib.sha256(evidence_key.encode("utf-8")).hexdigest()[:16]
|
|
477
|
+
evidence_nodes.setdefault(evidence_id, {"id": evidence_id, "kind": "evidence", **evidence})
|
|
478
|
+
graph_edges.append({"from": item["feedbackId"], "to": evidence_id, "kind": "evidenced-by"})
|
|
479
|
+
for relationship in item["relationships"]:
|
|
480
|
+
target = relationship["target"]
|
|
481
|
+
target_id = target if SAFE_FEEDBACK_ID_RE.fullmatch(target) else "reference-" + hashlib.sha256(target.encode("utf-8")).hexdigest()[:16]
|
|
482
|
+
if not any(node["id"] == target_id for node in graph_nodes):
|
|
483
|
+
graph_nodes.append({"id": target_id, "kind": "related-reference"})
|
|
484
|
+
graph_edges.append({"from": item["feedbackId"], "to": target_id, "kind": relationship["kind"], "confidence": relationship["confidence"]})
|
|
485
|
+
graph_nodes.extend(evidence_nodes.values())
|
|
486
|
+
graph_nodes = graph_nodes[:MAX_GRAPH_NODES]
|
|
487
|
+
allowed_node_ids = {node["id"] for node in graph_nodes}
|
|
488
|
+
graph_edges = [edge for edge in graph_edges if edge["from"] in allowed_node_ids and edge["to"] in allowed_node_ids][:MAX_GRAPH_EDGES]
|
|
489
|
+
evidence_usage = {}
|
|
490
|
+
for item in records:
|
|
491
|
+
for evidence in item["evidence"]:
|
|
492
|
+
key = f"{evidence['kind']}:{evidence['ref']}"
|
|
493
|
+
evidence_usage.setdefault(key, []).append(item["feedbackId"])
|
|
494
|
+
remediation_suggestions = []
|
|
495
|
+
if duplicates:
|
|
496
|
+
remediation_suggestions.append("Review duplicate fingerprints or summaries and link one canonical remediation issue before implementation.")
|
|
497
|
+
if len(records) != expected_count:
|
|
498
|
+
remediation_suggestions.append("Collect the missing batch indexes before treating this batch as complete.")
|
|
499
|
+
if any(len(ids) > 1 for ids in evidence_usage.values()):
|
|
500
|
+
remediation_suggestions.append("Inspect shared evidence references for a common root cause and reuse one regression test where appropriate.")
|
|
501
|
+
if not any(item["evidence"] for item in records):
|
|
502
|
+
remediation_suggestions.append("Attach bounded test, report, screenshot, command, or deployment references before release review.")
|
|
393
503
|
output = Path(args.output) if args.output else project / ".maggie" / "feedback" / "batches" / f"{batch_id}-review.json"
|
|
394
504
|
if not output.is_absolute(): output = project / output
|
|
395
505
|
report = {
|
|
@@ -403,6 +513,8 @@ def batch_review(args: argparse.Namespace) -> int:
|
|
|
403
513
|
"phases": sorted({item["phase"] for item in records if item["phase"]}),
|
|
404
514
|
"observations": records,
|
|
405
515
|
"duplicates": duplicates,
|
|
516
|
+
"evidenceGraph": {"nodes": graph_nodes, "edges": graph_edges},
|
|
517
|
+
"remediationSuggestions": remediation_suggestions,
|
|
406
518
|
"privacy": {"rawPayloadsIncluded": False, "localPathsIncluded": False, "secretsRedacted": True},
|
|
407
519
|
}
|
|
408
520
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -446,6 +558,8 @@ def main() -> int:
|
|
|
446
558
|
collect_parser.add_argument("--affected-cli", default="")
|
|
447
559
|
collect_parser.add_argument("--route-id", dest="route_ids", action="append", default=[])
|
|
448
560
|
collect_parser.add_argument("--validation-evidence", dest="validation_evidence", action="append", default=[])
|
|
561
|
+
collect_parser.add_argument("--evidence-ref", dest="evidence", action="append", default=[], help="safe kind:reference pointer; no file contents are copied")
|
|
562
|
+
collect_parser.add_argument("--relationship", dest="relationships", action="append", default=[], help="safe relation=target pointer")
|
|
449
563
|
batch_parser = sub.add_parser("batch")
|
|
450
564
|
batch_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
451
565
|
batch_parser.add_argument("--batch-file", required=True)
|
|
@@ -16,6 +16,7 @@ from agent_runtime import preflight as agent_preflight
|
|
|
16
16
|
from restart_gate import validate as validate_restart_gate
|
|
17
17
|
from google_capabilities import validate_report as validate_google_capability_report
|
|
18
18
|
from maggie_favicon import check_favicon
|
|
19
|
+
from external_write import validate as validate_external_write
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
REQUIRED_ROUTES = (
|
|
@@ -176,12 +177,40 @@ def command_google_capabilities(args: argparse.Namespace) -> int:
|
|
|
176
177
|
"status": "passed" if result["passed"] else "failed",
|
|
177
178
|
"report": str(output),
|
|
178
179
|
"providerCount": len(result["providerResults"]),
|
|
180
|
+
"verifiedTargets": [
|
|
181
|
+
{
|
|
182
|
+
"provider": item["provider"],
|
|
183
|
+
"activeAccount": item["activeAccount"],
|
|
184
|
+
"target": item["target"],
|
|
185
|
+
"scopes": item["scopes"],
|
|
186
|
+
}
|
|
187
|
+
for item in result["providerResults"]
|
|
188
|
+
],
|
|
179
189
|
"failedChecks": result["errors"],
|
|
180
190
|
"mutationsAllowed": False,
|
|
181
191
|
}, indent=2, ensure_ascii=False))
|
|
182
192
|
return 0 if result["passed"] else 1
|
|
183
193
|
|
|
184
194
|
|
|
195
|
+
def command_external_write_gate(args: argparse.Namespace) -> int:
|
|
196
|
+
evidence = read_json(Path(args.evidence).resolve())
|
|
197
|
+
result = validate_external_write(evidence)
|
|
198
|
+
output = Path(args.output).resolve() if args.output else root(args) / ".maggie" / "external-write-readback.json"
|
|
199
|
+
write_json(output, result)
|
|
200
|
+
print(json.dumps({
|
|
201
|
+
"status": "passed" if result["passed"] else "failed",
|
|
202
|
+
"report": str(output),
|
|
203
|
+
"provider": result.get("provider"),
|
|
204
|
+
"resource": result.get("resource"),
|
|
205
|
+
"operation": result.get("operation"),
|
|
206
|
+
"idempotencyKey": result.get("idempotencyKey"),
|
|
207
|
+
"readback": result.get("readback"),
|
|
208
|
+
"failedChecks": result["errors"],
|
|
209
|
+
"mutation": "not executed",
|
|
210
|
+
}, indent=2, ensure_ascii=False))
|
|
211
|
+
return 0 if result["passed"] else 1
|
|
212
|
+
|
|
213
|
+
|
|
185
214
|
def command_favicon_check(args: argparse.Namespace) -> int:
|
|
186
215
|
result = check_favicon(args.origin, args.declared_url, args.timeout)
|
|
187
216
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
@@ -210,6 +239,10 @@ def main() -> int:
|
|
|
210
239
|
google.add_argument("--report", required=True)
|
|
211
240
|
google.add_argument("--output")
|
|
212
241
|
google.set_defaults(func=command_google_capabilities)
|
|
242
|
+
external_write = sub.add_parser("external-write-gate", help="validate idempotent provider write/readback evidence")
|
|
243
|
+
external_write.add_argument("--evidence", required=True)
|
|
244
|
+
external_write.add_argument("--output")
|
|
245
|
+
external_write.set_defaults(func=command_external_write_gate)
|
|
213
246
|
favicon = sub.add_parser("favicon-check", help="fetch /favicon.ico and the declared icon to verify behaviour")
|
|
214
247
|
favicon.add_argument("--origin", required=True)
|
|
215
248
|
favicon.add_argument("--declared-url")
|