@topy-ai/maggie 0.7.23 → 0.7.25
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 +63 -8
- package/README.zh-TW.md +7 -1
- package/bin/maggie.js +9 -2
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
- package/bundled-contracts/maggie-deployment/runtime-parity-v1.schema.json +38 -0
- package/bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json +99 -0
- package/bundled-contracts/maggie-service-booking/onboarding-orchestration-v1.schema.json +34 -0
- package/bundled-skills/maggie-deployment/SKILL.md +27 -5
- package/bundled-skills/maggie-design/SKILL.md +41 -5
- package/bundled-skills/maggie-feedback/SKILL.md +36 -1
- package/bundled-skills/maggie-qa-workflow/SKILL.md +9 -5
- package/bundled-skills/maggie-service-booking/SKILL.md +35 -1
- package/bundled-tools/clis/maggie_deployment_parity.py +170 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +14 -0
- package/bundled-tools/clis/maggie_design.py +134 -1
- package/bundled-tools/clis/maggie_feedback.py +100 -2
- package/bundled-tools/clis/maggie_qa_workflow.py +14 -1
- package/bundled-tools/clis/maggie_service_onboarding.py +186 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-service-booking
|
|
3
3
|
description: Import, synchronise, validate, and design SPA service pages from a booking provider such as Fresha. Use for service catalogues, treatment variants, prices, durations, booking links, payment links, and booking-aware page generation.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.3.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Service Booking
|
|
@@ -136,6 +136,13 @@ python3 tools/clis/maggie_service_booking.py retirement-audit \
|
|
|
136
136
|
--project . \
|
|
137
137
|
--catalogue .maggie/booking/services.json \
|
|
138
138
|
--evidence .maggie/booking/retirement-evidence.json
|
|
139
|
+
|
|
140
|
+
# Validate independent, pollable onboarding jobs before provider research or
|
|
141
|
+
# public service generation. The manifest contains no credentials or payloads.
|
|
142
|
+
maggie service orchestration-validate \
|
|
143
|
+
--project . \
|
|
144
|
+
--manifest .maggie/booking/onboarding-orchestration.json \
|
|
145
|
+
--output docs/service-onboarding-orchestration.json
|
|
139
146
|
```
|
|
140
147
|
|
|
141
148
|
`import` creates the first catalogue. `sync` compares the newly imported
|
|
@@ -159,6 +166,33 @@ evidence artifact and validates the selected ending (`pending`, `redirect`,
|
|
|
159
166
|
`tombstone`, or `gone`), HTTP status, booking suppression, unavailable/noindex
|
|
160
167
|
signals, and sitemap exclusion. It never stores response bodies or decides a
|
|
161
168
|
redirect destination for the host.
|
|
169
|
+
|
|
170
|
+
## Asynchronous onboarding contract
|
|
171
|
+
|
|
172
|
+
Provider onboarding is three independent state machines: `import` owns the
|
|
173
|
+
provider catalogue, `search` owns local discovery results, and `research` owns
|
|
174
|
+
website research. Each job must have a unique `jobId`, unique
|
|
175
|
+
`idempotencyKey`, bounded `attempt`/`maxAttempts`, a current state and history,
|
|
176
|
+
an owner, a pollable `statusRef`, and an explicit `nextAction`. State history
|
|
177
|
+
is validated so retries cannot silently become successes. `fieldOwnership`
|
|
178
|
+
ensures that one workflow cannot overwrite another workflow's fields; the host
|
|
179
|
+
adapter remains responsible for actual persistence and queue execution.
|
|
180
|
+
|
|
181
|
+
Validate the redacted orchestration snapshot before provider research and
|
|
182
|
+
again before public generation:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
maggie service orchestration-validate \
|
|
186
|
+
--project . \
|
|
187
|
+
--manifest .maggie/booking/onboarding-orchestration.json \
|
|
188
|
+
--output docs/service-onboarding-orchestration.json
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The contract is documented in
|
|
192
|
+
[`onboarding-orchestration-v1.schema.json`](../../bundled-contracts/maggie-service-booking/onboarding-orchestration-v1.schema.json).
|
|
193
|
+
It is provider-neutral and does not accept credentials, provider response
|
|
194
|
+
bodies, or arbitrary filesystem paths in the poll reference.
|
|
195
|
+
|
|
162
196
|
`convert-page` imports a normal page as a draft service without inventing
|
|
163
197
|
booking facts. `match-pages` reads filesystem pages plus `docs/pages.json` and
|
|
164
198
|
`docs/page-content.json`, writes candidate evidence to both `.maggie/booking`
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate provider-neutral worker/browser runtime parity evidence before canary traffic."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SCHEMA = "maggie-deployment-runtime-parity.v1"
|
|
15
|
+
ENVIRONMENTS = {"development", "staging", "production"}
|
|
16
|
+
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(path: Path) -> dict[str, Any]:
|
|
20
|
+
try:
|
|
21
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
22
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
23
|
+
raise ValueError(f"cannot read parity evidence: {error}") from error
|
|
24
|
+
if not isinstance(value, dict):
|
|
25
|
+
raise ValueError("parity evidence root must be an object")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def non_empty_strings(value: object, field: str, errors: list[str]) -> list[str]:
|
|
30
|
+
if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
|
|
31
|
+
errors.append(f"{field} must be an array of non-empty strings")
|
|
32
|
+
return []
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate(payload: dict[str, Any]) -> list[str]:
|
|
37
|
+
errors: list[str] = []
|
|
38
|
+
if payload.get("schemaVersion") != SCHEMA:
|
|
39
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
40
|
+
if payload.get("environment") not in ENVIRONMENTS:
|
|
41
|
+
errors.append("environment must be development, staging, or production")
|
|
42
|
+
if payload.get("passed") is not True:
|
|
43
|
+
errors.append("parity evidence is not marked passed")
|
|
44
|
+
|
|
45
|
+
worker = payload.get("worker")
|
|
46
|
+
if not isinstance(worker, dict):
|
|
47
|
+
errors.append("worker evidence must be an object")
|
|
48
|
+
worker = {}
|
|
49
|
+
config = worker.get("config")
|
|
50
|
+
if not isinstance(config, dict):
|
|
51
|
+
errors.append("worker.config evidence must be an object")
|
|
52
|
+
config = {}
|
|
53
|
+
required = non_empty_strings(config.get("required"), "worker.config.required", errors)
|
|
54
|
+
missing = non_empty_strings(config.get("missing"), "worker.config.missing", errors)
|
|
55
|
+
if not required:
|
|
56
|
+
errors.append("worker.config.required must declare at least one requirement")
|
|
57
|
+
if missing:
|
|
58
|
+
errors.append("worker.config has missing requirements")
|
|
59
|
+
if config.get("passed") is not True:
|
|
60
|
+
errors.append("worker.config did not pass")
|
|
61
|
+
|
|
62
|
+
migration = worker.get("migration")
|
|
63
|
+
privilege = migration.get("privilegeCheck") if isinstance(migration, dict) else None
|
|
64
|
+
if not isinstance(privilege, dict) or privilege.get("passed") is not True:
|
|
65
|
+
errors.append("worker.migration.privilegeCheck did not pass")
|
|
66
|
+
|
|
67
|
+
routes = worker.get("routeApis")
|
|
68
|
+
if not isinstance(routes, list) or not routes:
|
|
69
|
+
errors.append("worker.routeApis must contain at least one route check")
|
|
70
|
+
routes = []
|
|
71
|
+
for index, route in enumerate(routes):
|
|
72
|
+
prefix = f"worker.routeApis[{index}]"
|
|
73
|
+
if not isinstance(route, dict):
|
|
74
|
+
errors.append(f"{prefix} must be an object")
|
|
75
|
+
continue
|
|
76
|
+
method = route.get("method")
|
|
77
|
+
path = route.get("path")
|
|
78
|
+
status = route.get("status")
|
|
79
|
+
if not isinstance(method, str) or not re.fullmatch(r"[A-Z]{3,10}", method):
|
|
80
|
+
errors.append(f"{prefix}.method must be an uppercase HTTP method")
|
|
81
|
+
if not isinstance(path, str) or not path.startswith("/") or "?" in path or "#" in path:
|
|
82
|
+
errors.append(f"{prefix}.path must be a local route path without query or fragment")
|
|
83
|
+
if not isinstance(status, int) or isinstance(status, bool) or not 200 <= status < 400:
|
|
84
|
+
errors.append(f"{prefix}.status must be a successful HTTP status")
|
|
85
|
+
if route.get("passed") is not True:
|
|
86
|
+
errors.append(f"{prefix} did not pass")
|
|
87
|
+
|
|
88
|
+
processes = worker.get("residentProcesses")
|
|
89
|
+
if not isinstance(processes, dict):
|
|
90
|
+
errors.append("worker.residentProcesses evidence must be an object")
|
|
91
|
+
processes = {}
|
|
92
|
+
for field in ("unexpected", "stale"):
|
|
93
|
+
values = non_empty_strings(processes.get(field), f"worker.residentProcesses.{field}", errors)
|
|
94
|
+
if values:
|
|
95
|
+
errors.append(f"worker.residentProcesses contains {field} processes")
|
|
96
|
+
if processes.get("checked") is not True or processes.get("passed") is not True:
|
|
97
|
+
errors.append("worker.residentProcesses did not pass")
|
|
98
|
+
|
|
99
|
+
jobs = worker.get("sourceJobs")
|
|
100
|
+
if not isinstance(jobs, list):
|
|
101
|
+
errors.append("worker.sourceJobs must be an array")
|
|
102
|
+
jobs = []
|
|
103
|
+
for index, job in enumerate(jobs):
|
|
104
|
+
prefix = f"worker.sourceJobs[{index}]"
|
|
105
|
+
if not isinstance(job, dict):
|
|
106
|
+
errors.append(f"{prefix} must be an object")
|
|
107
|
+
continue
|
|
108
|
+
job_id = job.get("jobId")
|
|
109
|
+
if not isinstance(job_id, str) or not SAFE_ID.fullmatch(job_id):
|
|
110
|
+
errors.append(f"{prefix}.jobId must be a non-empty safe identifier")
|
|
111
|
+
if job.get("passed") is not True:
|
|
112
|
+
errors.append(f"{prefix} did not pass")
|
|
113
|
+
|
|
114
|
+
browser = payload.get("browser")
|
|
115
|
+
if not isinstance(browser, dict):
|
|
116
|
+
errors.append("browser evidence must be an object")
|
|
117
|
+
browser = {}
|
|
118
|
+
for field in ("browserVersion", "driverVersion"):
|
|
119
|
+
if not isinstance(browser.get(field), str) or not browser[field].strip():
|
|
120
|
+
errors.append(f"browser.{field} is required")
|
|
121
|
+
if browser.get("compatible") is not True:
|
|
122
|
+
errors.append("browser and driver versions are not compatible")
|
|
123
|
+
return errors
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parity(evidence: Path, output: Path) -> int:
|
|
127
|
+
try:
|
|
128
|
+
payload = load(evidence)
|
|
129
|
+
errors = validate(payload)
|
|
130
|
+
except ValueError as error:
|
|
131
|
+
errors = [str(error)]
|
|
132
|
+
report = {
|
|
133
|
+
"schemaVersion": SCHEMA,
|
|
134
|
+
"environment": payload.get("environment") if "payload" in locals() else None,
|
|
135
|
+
"status": "passed" if not errors else "failed",
|
|
136
|
+
"passed": not errors,
|
|
137
|
+
"checks": {
|
|
138
|
+
"workerConfig": "validated",
|
|
139
|
+
"migrationPrivileges": "validated",
|
|
140
|
+
"routeApis": "validated",
|
|
141
|
+
"residentProcesses": "validated",
|
|
142
|
+
"sourceJobIds": "validated",
|
|
143
|
+
"browserDriverCompatibility": "validated",
|
|
144
|
+
},
|
|
145
|
+
"errors": errors,
|
|
146
|
+
}
|
|
147
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
149
|
+
print(json.dumps({"status": report["status"], "report": str(output.resolve()), "errors": errors}, indent=2, ensure_ascii=False))
|
|
150
|
+
return 0 if not errors else 1
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> int:
|
|
154
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
155
|
+
parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
156
|
+
parser.add_argument("--evidence", type=Path, required=True, help="sanitized host-adapter parity evidence JSON")
|
|
157
|
+
parser.add_argument("--output", type=Path, default=Path(".maggie/deployment-runtime-parity.json"))
|
|
158
|
+
args = parser.parse_args()
|
|
159
|
+
project = args.project.resolve()
|
|
160
|
+
evidence = args.evidence if args.evidence.is_absolute() else project / args.evidence
|
|
161
|
+
output = args.output if args.output.is_absolute() else project / args.output
|
|
162
|
+
try:
|
|
163
|
+
return parity(evidence, output)
|
|
164
|
+
except OSError as error:
|
|
165
|
+
print(f"BLOCKED: maggie deployment parity: {error}", file=sys.stderr)
|
|
166
|
+
return 2
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
raise SystemExit(main())
|
|
@@ -82,12 +82,23 @@ def validate_preflight(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
|
82
82
|
return True, "deployment preflight gates passed"
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def validate_runtime_parity(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
86
|
+
if payload.get("schemaVersion") != "maggie-deployment-runtime-parity.v1":
|
|
87
|
+
return False, "runtime parity evidence schemaVersion is unsupported"
|
|
88
|
+
if payload.get("status") != "passed" or payload.get("passed") is not True:
|
|
89
|
+
return False, "worker/browser runtime parity did not pass"
|
|
90
|
+
if payload.get("errors") not in ([], None):
|
|
91
|
+
return False, "runtime parity evidence contains errors"
|
|
92
|
+
return True, "worker/browser runtime parity passed"
|
|
93
|
+
|
|
94
|
+
|
|
85
95
|
VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
|
|
86
96
|
"unitRegression": validate_unit,
|
|
87
97
|
"packageSmoke": validate_package,
|
|
88
98
|
"browserEvidence": validate_browser,
|
|
89
99
|
"renderedCanary": validate_canary,
|
|
90
100
|
"deploymentPreflight": validate_preflight,
|
|
101
|
+
"runtimeParity": validate_runtime_parity,
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
|
|
@@ -138,6 +149,7 @@ def main() -> int:
|
|
|
138
149
|
parser.add_argument("--browser-report", type=Path, default=Path(".maggie/verification/browser-evidence.json"))
|
|
139
150
|
parser.add_argument("--rendered-canary", type=Path, default=Path(".maggie/deployment-canary.json"))
|
|
140
151
|
parser.add_argument("--deployment-preflight", type=Path, default=Path(".maggie/release-preflight.json"))
|
|
152
|
+
parser.add_argument("--runtime-parity", type=Path, help="validated worker/browser parity report from maggie deployment parity")
|
|
141
153
|
parser.add_argument("--output", type=Path)
|
|
142
154
|
args = parser.parse_args()
|
|
143
155
|
project = args.project.resolve()
|
|
@@ -147,6 +159,8 @@ def main() -> int:
|
|
|
147
159
|
name: (getattr(args, option).resolve() if getattr(args, option).is_absolute() else project / getattr(args, option))
|
|
148
160
|
for name, option in CHECKS
|
|
149
161
|
}
|
|
162
|
+
if args.runtime_parity:
|
|
163
|
+
paths["runtimeParity"] = args.runtime_parity.resolve() if args.runtime_parity.is_absolute() else project / args.runtime_parity
|
|
150
164
|
try:
|
|
151
165
|
return readiness(project, paths, args.output)
|
|
152
166
|
except (OSError, ValueError) as error:
|
|
@@ -157,6 +157,114 @@ def validate_content_ui(project: Path, plan_path: Path, rendered_dir: Path | Non
|
|
|
157
157
|
output = plan_path.parent / "validation.json"; output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"); print(json.dumps(result, indent=2, ensure_ascii=False)); return 0 if not errors else 1
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def app_surface_init(project: Path, route: str, auth_mode: str, force: bool, confirm: bool) -> int:
|
|
161
|
+
"""Create a host-owned mobile app surface contract and QA manifest."""
|
|
162
|
+
project = project.resolve()
|
|
163
|
+
if not route.startswith("/") or "?" in route or "#" in route:
|
|
164
|
+
raise ValueError("route must be a path beginning with / and must not contain a query or hash")
|
|
165
|
+
if auth_mode not in {"email-password", "existing-session", "none"}:
|
|
166
|
+
raise ValueError("auth mode must be email-password, existing-session, or none")
|
|
167
|
+
contract = require_design_contract(project)
|
|
168
|
+
relative = route.strip("/")
|
|
169
|
+
candidates = [
|
|
170
|
+
project / relative,
|
|
171
|
+
project / "src" / "pages" / relative,
|
|
172
|
+
project / "src" / "pages" / f"{relative}.astro",
|
|
173
|
+
project / "src" / "pages" / f"{relative}.html",
|
|
174
|
+
project / "src" / "app" / relative,
|
|
175
|
+
project / "src" / "app" / relative / "page.tsx",
|
|
176
|
+
]
|
|
177
|
+
source = next((path for path in candidates if path.is_file()), None)
|
|
178
|
+
digest = hashlib.sha256((str(project) + "\n" + route + "\n" + auth_mode).encode()).hexdigest()[:12]
|
|
179
|
+
output_dir = project / ".maggie" / "design" / "app-view" / f"app-{digest}"
|
|
180
|
+
if output_dir.exists() and not force:
|
|
181
|
+
print((output_dir / "plan.json").read_text(encoding="utf-8"), end="")
|
|
182
|
+
return 0
|
|
183
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
viewports = {"desktop": REVIEW_VIEWPORTS["desktop"], "tablet": REVIEW_VIEWPORTS["tablet"], "mobile": REVIEW_VIEWPORTS["mobile"]}
|
|
185
|
+
plan = {
|
|
186
|
+
"schemaVersion": "maggie-mobile-app-surface.v1",
|
|
187
|
+
"id": f"app-{digest}",
|
|
188
|
+
"workflow": "maggie-design",
|
|
189
|
+
"mode": "app-view-init",
|
|
190
|
+
"phase": "ready",
|
|
191
|
+
"route": route,
|
|
192
|
+
"routeSource": str(source) if source else None,
|
|
193
|
+
"designContract": contract,
|
|
194
|
+
"auth": {"mode": auth_mode, "required": auth_mode != "none", "credentialPolicy": "use-host-auth-without-recording-credentials"},
|
|
195
|
+
"shell": {"fixedHeader": True, "fixedFooter": True, "oneScrollOwner": True, "cameraMayUseFullScreenViewport": True, "hostShellSourceOfTruth": "existing homepage shell"},
|
|
196
|
+
"camera": {"requiredStates": ["permission", "ready", "gallery", "scan", "unavailable"], "controls": ["close", "gallery", "shutter", "mode"], "fallback": "show an actionable unavailable state; never leave a black screen"},
|
|
197
|
+
"decisionCriticalUi": ["card normalization", "graded-price fallback", "grade-9-to-10 economics", "scan-to-inventory or valuation transition"],
|
|
198
|
+
"viewports": viewports,
|
|
199
|
+
"requiredSteps": ["inspect-host-shell", "define-auth-gate", "define-camera-states", "capture-desktop-tablet-mobile", "signed-in-browser-qa", "accessibility-check", "route-validation", "explicit-approval"],
|
|
200
|
+
"provenance": {"hostOwnsAuthAndCamera": True, "designContractReused": True, "privateDataCopied": False},
|
|
201
|
+
"publicWrite": False,
|
|
202
|
+
"createdAt": datetime.now(timezone.utc).isoformat(),
|
|
203
|
+
}
|
|
204
|
+
evidence = {
|
|
205
|
+
"schemaVersion": "maggie-mobile-app-evidence.v1",
|
|
206
|
+
"route": route,
|
|
207
|
+
"auth": plan["auth"],
|
|
208
|
+
"shell": plan["shell"],
|
|
209
|
+
"camera": plan["camera"],
|
|
210
|
+
"viewports": {name: {"screenshot": f"{name}.png", "captured": False} for name in viewports},
|
|
211
|
+
"status": "pending",
|
|
212
|
+
}
|
|
213
|
+
qa = {"schemaVersion": "maggie-mobile-app-qa.v1", "route": route, "auth": plan["auth"], "scenarios": [
|
|
214
|
+
{"id": "shell-scroll", "viewport": "mobile", "assertions": ["fixed header and footer remain usable", "exactly one scroll owner"]},
|
|
215
|
+
{"id": "camera-permission", "viewport": "mobile", "assertions": ["permission state is explained", "denied permission shows fallback"]},
|
|
216
|
+
{"id": "camera-scan", "viewport": "mobile", "assertions": ["gallery/shutter/mode/close controls are reachable", "scan result can enter inventory or valuation"]},
|
|
217
|
+
{"id": "valuation-auth", "viewport": "desktop", "assertions": ["signed-in route does not redirect unexpectedly", "grade economics and fallback price remain legible"]},
|
|
218
|
+
]}
|
|
219
|
+
(output_dir / "plan.json").write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
220
|
+
(output_dir / "qa-scenarios.json").write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
221
|
+
(output_dir / "validation.json").write_text(json.dumps(evidence, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
222
|
+
(output_dir / "screenshots").mkdir(exist_ok=True)
|
|
223
|
+
print(json.dumps({"plan": str(output_dir / "plan.json"), "qaScenarios": str(output_dir / "qa-scenarios.json"), "surface": "app", "phase": "ready", "publicWrite": False}, indent=2, ensure_ascii=False))
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def validate_app_surface(project: Path, plan_path: Path, rendered_dir: Path | None, runtime_evidence: Path | None, confirm: bool) -> int:
|
|
228
|
+
"""Validate signed-in app evidence and responsive screenshots."""
|
|
229
|
+
project = project.resolve(); plan_path = plan_path.resolve()
|
|
230
|
+
if not plan_path.exists(): raise ValueError(f"app surface plan not found: {plan_path}")
|
|
231
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []
|
|
232
|
+
if plan.get("workflow") != "maggie-design" or plan.get("mode") != "app-view-init": errors.append("invalid mobile app surface plan")
|
|
233
|
+
if plan.get("publicWrite") is not False: errors.append("publicWrite must remain false before approval")
|
|
234
|
+
if not plan.get("shell", {}).get("oneScrollOwner"): errors.append("app surface must define one scroll owner")
|
|
235
|
+
if not plan.get("auth", {}).get("credentialPolicy"): errors.append("app surface must define a credential policy")
|
|
236
|
+
try: require_design_contract(project)
|
|
237
|
+
except ValueError as error: errors.append(str(error))
|
|
238
|
+
evidence = {}
|
|
239
|
+
evidence_path = runtime_evidence.resolve() if runtime_evidence else plan_path.parent / "runtime-evidence.json"
|
|
240
|
+
if evidence_path.is_file():
|
|
241
|
+
try: evidence = json.loads(evidence_path.read_text(encoding="utf-8"))
|
|
242
|
+
except json.JSONDecodeError: errors.append("runtime evidence is not valid JSON")
|
|
243
|
+
else:
|
|
244
|
+
errors.append("runtime evidence file is required")
|
|
245
|
+
if evidence:
|
|
246
|
+
if evidence.get("schemaVersion") != "maggie-mobile-app-evidence.v1": errors.append("runtime evidence schemaVersion is invalid")
|
|
247
|
+
if evidence.get("route") != plan.get("route"): errors.append("runtime evidence route does not match plan")
|
|
248
|
+
auth = evidence.get("auth", {})
|
|
249
|
+
if plan.get("auth", {}).get("required") and auth.get("signedIn") is not True: errors.append("signed-in evidence is required for this app route")
|
|
250
|
+
shell = evidence.get("shell", {})
|
|
251
|
+
if shell.get("fixedHeader") is not True or shell.get("fixedFooter") is not True or shell.get("scrollOwnerCount") != 1: errors.append("runtime evidence must prove fixed shell and one scroll owner")
|
|
252
|
+
camera = evidence.get("camera", {})
|
|
253
|
+
camera_states = set(camera.get("states", [])) if isinstance(camera, dict) else set()
|
|
254
|
+
if not set(plan.get("camera", {}).get("requiredStates", [])).issubset(camera_states): errors.append("runtime evidence is missing a required camera state")
|
|
255
|
+
screenshot_status = {}
|
|
256
|
+
for name in ("desktop", "tablet", "mobile"):
|
|
257
|
+
path = (rendered_dir / f"{name}.png") if rendered_dir else plan_path.parent / "screenshots" / f"{name}.png"
|
|
258
|
+
valid = path.is_file() and path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
|
|
259
|
+
screenshot_status[name] = {"path": str(path), "validPng": valid}
|
|
260
|
+
if not valid: errors.append(f"missing valid {name} screenshot")
|
|
261
|
+
result = {"schemaVersion": "maggie-mobile-app-validation.v1", "plan": str(plan_path), "route": plan.get("route"), "screenshots": screenshot_status, "runtimeEvidence": str(evidence_path), "checks": {"fixedShell": not any("fixed shell" in error for error in errors), "signedInRoute": not any("signed-in" in error for error in errors), "cameraStates": not any("camera state" in error for error in errors), "publicWriteBlocked": plan.get("publicWrite") is False}, "status": "passed" if not errors else "failed", "errors": errors, "validatedAt": datetime.now(timezone.utc).isoformat()}
|
|
262
|
+
if confirm:
|
|
263
|
+
plan_path.parent.joinpath("validation.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
264
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
265
|
+
return 0 if not errors else 1
|
|
266
|
+
|
|
267
|
+
|
|
160
268
|
def rebrand_template(args: argparse.Namespace) -> int:
|
|
161
269
|
"""Apply an explicit brand identity to a packaged homepage template."""
|
|
162
270
|
template = args.template.resolve()
|
|
@@ -547,6 +655,7 @@ def in_place_job(project: Path, routes: list[str], force: bool = False, content_
|
|
|
547
655
|
project / relative,
|
|
548
656
|
project / "src" / "pages" / relative,
|
|
549
657
|
project / "src" / "app" / relative,
|
|
658
|
+
project / "src" / "pages" / f"{relative}.astro",
|
|
550
659
|
project / "src" / "pages" / f"{relative}.html",
|
|
551
660
|
project / "src" / "app" / relative / "page.tsx",
|
|
552
661
|
]
|
|
@@ -680,7 +789,7 @@ commands:
|
|
|
680
789
|
status show a design job and its per-step progress
|
|
681
790
|
resume restart a failed design workflow
|
|
682
791
|
author plan an original first-party page
|
|
683
|
-
reference-ui, init, validate-ui, rebrand, review
|
|
792
|
+
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
684
793
|
""")
|
|
685
794
|
return 0
|
|
686
795
|
if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
|
|
@@ -719,6 +828,30 @@ commands:
|
|
|
719
828
|
return validate_content_ui(args.project, args.plan, args.rendered_dir, args.confirm)
|
|
720
829
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
721
830
|
print(f"BLOCKED: maggie-design validate-ui: {error}", file=sys.stderr); return 1
|
|
831
|
+
if len(sys.argv) > 1 and sys.argv[1] == "app-init":
|
|
832
|
+
command = argparse.ArgumentParser(description="Create a mobile app surface contract with signed-in QA scenarios.")
|
|
833
|
+
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
834
|
+
command.add_argument("--route", required=True)
|
|
835
|
+
command.add_argument("--auth-mode", choices=["email-password", "existing-session", "none"], default="email-password")
|
|
836
|
+
command.add_argument("--force", action="store_true")
|
|
837
|
+
command.add_argument("--confirm", action="store_true")
|
|
838
|
+
args = command.parse_args(sys.argv[2:])
|
|
839
|
+
try:
|
|
840
|
+
return app_surface_init(args.project, args.route, args.auth_mode, args.force, args.confirm)
|
|
841
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
842
|
+
print(f"BLOCKED: maggie-design app-init: {error}", file=sys.stderr); return 1
|
|
843
|
+
if len(sys.argv) > 1 and sys.argv[1] == "app-validate":
|
|
844
|
+
command = argparse.ArgumentParser(description="Validate mobile app runtime evidence and responsive screenshots.")
|
|
845
|
+
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
846
|
+
command.add_argument("--plan", type=Path, required=True)
|
|
847
|
+
command.add_argument("--rendered-dir", type=Path)
|
|
848
|
+
command.add_argument("--runtime-evidence", type=Path)
|
|
849
|
+
command.add_argument("--confirm", action="store_true")
|
|
850
|
+
args = command.parse_args(sys.argv[2:])
|
|
851
|
+
try:
|
|
852
|
+
return validate_app_surface(args.project, args.plan, args.rendered_dir, args.runtime_evidence, args.confirm)
|
|
853
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
854
|
+
print(f"BLOCKED: maggie-design app-validate: {error}", file=sys.stderr); return 1
|
|
722
855
|
if len(sys.argv) > 1 and sys.argv[1] == "rebrand":
|
|
723
856
|
rebrand = argparse.ArgumentParser(description="Apply an explicit brand identity to a packaged marketplace template.")
|
|
724
857
|
rebrand.add_argument("--template", type=Path, required=True)
|
|
@@ -27,6 +27,7 @@ LOCAL_PATH_RE = re.compile(
|
|
|
27
27
|
r"\\\\[^\\/\s,;:)\]}]+(?:[\\/]+[^\\/\s,;:)\]}]+)+)"
|
|
28
28
|
)
|
|
29
29
|
VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
|
|
30
|
+
SAFE_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
|
|
30
31
|
ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
|
|
31
32
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
32
33
|
|
|
@@ -45,10 +46,25 @@ def project_fingerprint(project: Path) -> str:
|
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
def installed_version(project: Path) -> str:
|
|
48
|
-
"""Resolve the exact runtime version
|
|
49
|
+
"""Resolve the exact runtime version, preferring the running package."""
|
|
49
50
|
if os.environ.get("MAGGIE_VERSION"):
|
|
50
51
|
value = safe_text(os.environ["MAGGIE_VERSION"])
|
|
51
52
|
return value if VERSION_RE.fullmatch(value) else "unknown"
|
|
53
|
+
|
|
54
|
+
# A consumer project can retain an old .maggie/install.json after npm
|
|
55
|
+
# upgrade. The package actually being executed is stronger evidence.
|
|
56
|
+
package_versions = []
|
|
57
|
+
for parent in (project.resolve(), *project.resolve().parents):
|
|
58
|
+
package_path = parent / "node_modules" / "@topy-ai" / "maggie" / "package.json"
|
|
59
|
+
try:
|
|
60
|
+
value = json.loads(package_path.read_text(encoding="utf-8")).get("version", "")
|
|
61
|
+
if VERSION_RE.fullmatch(str(value)):
|
|
62
|
+
package_versions.append(str(value))
|
|
63
|
+
except (OSError, ValueError, json.JSONDecodeError, AttributeError):
|
|
64
|
+
continue
|
|
65
|
+
if package_versions:
|
|
66
|
+
return package_versions[0] if len(set(package_versions)) == 1 else "unknown"
|
|
67
|
+
|
|
52
68
|
install_path = project / ".maggie" / "install.json"
|
|
53
69
|
try:
|
|
54
70
|
value = json.loads(install_path.read_text(encoding="utf-8")).get("version", "")
|
|
@@ -102,6 +118,20 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
102
118
|
validation = safe_text(args.validation or report.get("validation"))
|
|
103
119
|
if fixed and (not resolution or not validation):
|
|
104
120
|
raise ValueError("fixed feedback requires non-empty --resolution and --validation")
|
|
121
|
+
batch_id = safe_text(getattr(args, "batch_id", ""))
|
|
122
|
+
batch_index = getattr(args, "batch_index", None)
|
|
123
|
+
batch_size = getattr(args, "batch_size", None)
|
|
124
|
+
if batch_id:
|
|
125
|
+
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
126
|
+
raise ValueError("batch ID is invalid")
|
|
127
|
+
if not isinstance(batch_index, int) or isinstance(batch_index, bool) or batch_index < 0:
|
|
128
|
+
raise ValueError("batch index is required when batch ID is provided")
|
|
129
|
+
if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1 or batch_index >= batch_size:
|
|
130
|
+
raise ValueError("batch size must be positive and greater than batch index")
|
|
131
|
+
priority = safe_text(getattr(args, "priority", "") or "normal").lower()
|
|
132
|
+
if priority not in {"low", "normal", "high", "critical"}:
|
|
133
|
+
raise ValueError("priority must be low, normal, high, or critical")
|
|
134
|
+
affected_cli = safe_text(getattr(args, "affected_cli", ""))
|
|
105
135
|
context = {"projectFingerprint": project_fingerprint(project)}
|
|
106
136
|
if args.allow_project_context and args.context_note:
|
|
107
137
|
context["note"] = safe_text(args.context_note)
|
|
@@ -124,12 +154,16 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
124
154
|
"fixed": fixed,
|
|
125
155
|
"resolution": resolution,
|
|
126
156
|
"validation": validation,
|
|
157
|
+
"priority": priority,
|
|
158
|
+
"affectedCli": affected_cli,
|
|
127
159
|
"attachments": [attachment(value) for value in args.screenshot],
|
|
128
160
|
"environment": {"os": platform.system().lower(), "python": platform.python_version()},
|
|
129
161
|
"privacy": {"secretsRedacted": True, "projectContextAllowed": bool(args.allow_project_context)},
|
|
130
162
|
"projectContext": context if args.allow_project_context else None,
|
|
131
163
|
"submission": {"status": "draft"},
|
|
132
164
|
}
|
|
165
|
+
if batch_id:
|
|
166
|
+
data["batch"] = {"batchId": batch_id, "index": batch_index, "size": batch_size}
|
|
133
167
|
directory = project / ".maggie" / "feedback"
|
|
134
168
|
directory.mkdir(parents=True, exist_ok=True)
|
|
135
169
|
path = directory / f"{data['feedbackId']}.json"
|
|
@@ -144,7 +178,11 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
144
178
|
|
|
145
179
|
|
|
146
180
|
def to_markdown(data: dict) -> str:
|
|
147
|
-
|
|
181
|
+
batch = data.get("batch") or {}
|
|
182
|
+
lines = [f"# Maggie feedback: {data.get('summary') or data.get('type')}", "", "<!-- Generated by maggie feedback. Secrets and local paths are omitted. -->", "", "## Run", "", f"- Skill: `{data.get('skill')}`", f"- Maggie version: `{data.get('maggieVersion')}`", f"- Run ID: `{data.get('runId') or 'not provided'}`", f"- Phase: `{data.get('phase') or 'not provided'}`", f"- Status: `{data.get('status')}`", f"- Priority: `{data.get('priority') or 'normal'}`", f"- Affected CLI: `{data.get('affectedCli') or 'not provided'}`", f"- Error fingerprint: `{data.get('errorFingerprint') or 'not provided'}`"]
|
|
183
|
+
if batch:
|
|
184
|
+
lines.append(f"- Batch: `{batch.get('batchId')}` ({batch.get('index', 0) + 1}/{batch.get('size')})")
|
|
185
|
+
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'}", ""]
|
|
148
186
|
if data.get("stepsToReproduce"):
|
|
149
187
|
lines += ["**Steps to reproduce:**", ""] + [f"{index}. {step}" for index, step in enumerate(data["stepsToReproduce"], 1)] + [""]
|
|
150
188
|
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)", ""]
|
|
@@ -211,6 +249,57 @@ def list_feedback(args: argparse.Namespace) -> int:
|
|
|
211
249
|
return 0
|
|
212
250
|
|
|
213
251
|
|
|
252
|
+
def collect_batch(args: argparse.Namespace) -> int:
|
|
253
|
+
"""Create one privacy-safe draft per item while retaining shared batch context."""
|
|
254
|
+
manifest = load_json(Path(args.batch_file).expanduser().resolve())
|
|
255
|
+
batch_id = safe_text(manifest.get("batchId"))
|
|
256
|
+
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
257
|
+
raise ValueError("batch file requires a safe batchId")
|
|
258
|
+
items = manifest.get("items")
|
|
259
|
+
if not isinstance(items, list) or not items or len(items) > 50:
|
|
260
|
+
raise ValueError("batch file requires between 1 and 50 items")
|
|
261
|
+
shared = {
|
|
262
|
+
"skill": safe_text(manifest.get("skill")),
|
|
263
|
+
"run_id": safe_text(manifest.get("runId")),
|
|
264
|
+
"phase": safe_text(manifest.get("phase")),
|
|
265
|
+
"priority": safe_text(manifest.get("priority") or "normal"),
|
|
266
|
+
"affected_cli": safe_text(manifest.get("affectedCli")),
|
|
267
|
+
}
|
|
268
|
+
paths = []
|
|
269
|
+
for index, item in enumerate(items):
|
|
270
|
+
if not isinstance(item, dict):
|
|
271
|
+
raise ValueError(f"items[{index}] must be an object")
|
|
272
|
+
values = {
|
|
273
|
+
"project": args.project,
|
|
274
|
+
"run_report": None,
|
|
275
|
+
"skill": safe_text(item.get("skill") or shared["skill"]),
|
|
276
|
+
"run_id": safe_text(item.get("runId") or shared["run_id"]),
|
|
277
|
+
"type": item.get("type", "bug"),
|
|
278
|
+
"phase": safe_text(item.get("phase") or shared["phase"]),
|
|
279
|
+
"summary": item.get("summary", ""),
|
|
280
|
+
"expected": item.get("expected", ""),
|
|
281
|
+
"actual": item.get("actual", ""),
|
|
282
|
+
"error_fingerprint": item.get("errorFingerprint", ""),
|
|
283
|
+
"reproduce": item.get("stepsToReproduce", []),
|
|
284
|
+
"fixed": item.get("fixed") is True,
|
|
285
|
+
"resolution": item.get("resolution", ""),
|
|
286
|
+
"validation": item.get("validation", ""),
|
|
287
|
+
"screenshot": item.get("screenshots", []),
|
|
288
|
+
"allow_project_context": False,
|
|
289
|
+
"context_note": "",
|
|
290
|
+
"batch_id": batch_id,
|
|
291
|
+
"batch_index": index,
|
|
292
|
+
"batch_size": len(items),
|
|
293
|
+
"priority": item.get("priority") or shared["priority"],
|
|
294
|
+
"affected_cli": item.get("affectedCli") or shared["affected_cli"],
|
|
295
|
+
}
|
|
296
|
+
if not isinstance(values["reproduce"], list): values["reproduce"] = []
|
|
297
|
+
if not isinstance(values["screenshot"], list): values["screenshot"] = []
|
|
298
|
+
paths.append(collect(argparse.Namespace(**values)))
|
|
299
|
+
print(json.dumps({"batchId": batch_id, "count": len(paths), "status": "drafts-created"}, ensure_ascii=False))
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
214
303
|
def main() -> int:
|
|
215
304
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
216
305
|
parser.add_argument("--project", default=".")
|
|
@@ -235,6 +324,14 @@ def main() -> int:
|
|
|
235
324
|
collect_parser.add_argument("--screenshot", action="append", default=[])
|
|
236
325
|
collect_parser.add_argument("--allow-project-context", action="store_true")
|
|
237
326
|
collect_parser.add_argument("--context-note", default="")
|
|
327
|
+
collect_parser.add_argument("--batch-id", default="")
|
|
328
|
+
collect_parser.add_argument("--batch-index", type=int)
|
|
329
|
+
collect_parser.add_argument("--batch-size", type=int)
|
|
330
|
+
collect_parser.add_argument("--priority", choices=("low", "normal", "high", "critical"), default="normal")
|
|
331
|
+
collect_parser.add_argument("--affected-cli", default="")
|
|
332
|
+
batch_parser = sub.add_parser("batch")
|
|
333
|
+
batch_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
334
|
+
batch_parser.add_argument("--batch-file", required=True)
|
|
238
335
|
preview_parser = sub.add_parser("preview")
|
|
239
336
|
preview_parser.add_argument("feedback")
|
|
240
337
|
preview_parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
@@ -246,6 +343,7 @@ def main() -> int:
|
|
|
246
343
|
args = parser.parse_args()
|
|
247
344
|
try:
|
|
248
345
|
if args.command == "collect": return collect(args)
|
|
346
|
+
if args.command == "batch": return collect_batch(args)
|
|
249
347
|
if args.command == "preview": return preview(args)
|
|
250
348
|
if args.command == "submit": return submit(args)
|
|
251
349
|
return list_feedback(args)
|
|
@@ -23,7 +23,7 @@ DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
|
|
|
23
23
|
VALID_STATUSES = {"pending", "pass", "fail", "blocked", "inconclusive"}
|
|
24
24
|
VALID_PHASES = {"test", "fix", "retest"}
|
|
25
25
|
ASSERTION_SCHEMA = "maggie.qa-assertions.v1"
|
|
26
|
-
RUNTIME_ASSERTION_TYPES = {"http-status", "final-url", "text-present", "text-absent", "meta", "link-absent", "redirect", "scroll-owner-count"}
|
|
26
|
+
RUNTIME_ASSERTION_TYPES = {"http-status", "final-url", "text-present", "text-absent", "meta", "link-absent", "redirect", "scroll-owner-count", "icon-rendered"}
|
|
27
27
|
SOURCE_ASSERTION_TYPES = {"class-present", "class-absent", "markup-present", "markup-absent", "source-text"}
|
|
28
28
|
SECRET_RE = re.compile(
|
|
29
29
|
r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
|
|
@@ -384,6 +384,19 @@ def assertion_audit(args: argparse.Namespace) -> int:
|
|
|
384
384
|
item_errors.append(f"checks[{check_index}].expected must be 1 scroll owner")
|
|
385
385
|
if isinstance(check.get("actual"), int) and not isinstance(check.get("actual"), bool) and check.get("actual") != 1:
|
|
386
386
|
item_errors.append(f"checks[{check_index}] found more or fewer than one scroll owner")
|
|
387
|
+
if check_type == "icon-rendered":
|
|
388
|
+
actual = check.get("actual")
|
|
389
|
+
if not isinstance(actual, dict):
|
|
390
|
+
item_errors.append(f"checks[{check_index}].actual must be an icon runtime measurement object")
|
|
391
|
+
else:
|
|
392
|
+
if actual.get("visible") is not True:
|
|
393
|
+
item_errors.append(f"checks[{check_index}] did not find a visible icon glyph")
|
|
394
|
+
for dimension in ("width", "height"):
|
|
395
|
+
value = actual.get(dimension)
|
|
396
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
|
|
397
|
+
item_errors.append(f"checks[{check_index}].actual.{dimension} must be a positive rendered dimension")
|
|
398
|
+
if actual.get("accessibleLabel") is not True:
|
|
399
|
+
item_errors.append(f"checks[{check_index}] icon control is missing an accessible label")
|
|
387
400
|
if any(isinstance(check, dict) and check.get("passed") is False for check in checks):
|
|
388
401
|
item_errors.append("one or more runtime checks failed")
|
|
389
402
|
audited.append({"id": identifier, "passed": not item_errors, "errors": item_errors})
|