@topy-ai/maggie 0.7.4 → 0.7.6
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 +34 -6
- package/README.zh-TW.md +5 -1
- package/bin/maggie.js +18 -2
- package/bundled-contracts/maggie-deployment/release-runner-v1.json +14 -0
- package/bundled-contracts/maggiedash/README.md +6 -0
- package/bundled-contracts/maggiedash/agent-content-v1.json +17 -0
- package/bundled-contracts/maggiedash/section-identity-v1.json +14 -0
- package/bundled-contracts/maggiedash/translation-cache-policy-v1.json +15 -0
- package/bundled-references/localization-extraction-render-prd.md +55 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +1 -1
- package/bundled-skills/maggie-dash/SKILL.md +53 -1
- package/bundled-skills/maggie-deployment/SKILL.md +20 -2
- package/bundled-skills/maggie-feedback/SKILL.md +4 -1
- package/bundled-skills/maggie-ops/SKILL.md +17 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +20 -0
- package/bundled-templates/maggiedash/section-registry.json +12 -0
- package/bundled-tools/clis/maggie_agent_content.py +135 -0
- package/bundled-tools/clis/maggie_dash.py +44 -0
- package/bundled-tools/clis/maggie_deployment.py +51 -0
- package/bundled-tools/clis/maggie_feedback.py +35 -7
- package/bundled-tools/clis/maggie_ops.py +23 -0
- package/bundled-tools/clis/maggie_verification.py +33 -0
- package/bundled-tools/runtime/agent_runtime.py +38 -0
- package/bundled-tools/runtime/maggie_dash_store.py +51 -0
- package/bundled-tools/runtime/maggie_sections.py +143 -0
- package/bundled-tools/runtime/restart_gate.py +22 -0
- package/bundled-tools/runtime/surface_coverage.py +122 -0
- package/package.json +1 -1
- package/references/localization-extraction-render-prd.md +55 -0
|
@@ -23,6 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
|
23
23
|
from maggie_dash_store import MaggieDashStore # noqa: E402
|
|
24
24
|
from service_variants import ServiceVariantStore # noqa: E402
|
|
25
25
|
from maggie_dash_ui import load_and_validate # noqa: E402
|
|
26
|
+
from maggie_sections import catalogue, section_id_migration, validate_registry, copy_notes # noqa: E402
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def project_root(args: argparse.Namespace) -> Path:
|
|
@@ -296,6 +297,13 @@ def command_cms(args: argparse.Namespace) -> int:
|
|
|
296
297
|
result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
|
|
297
298
|
elif args.cms_command == "preview":
|
|
298
299
|
result = store.issue_preview(args.project_id, args.document_id, args.secret, args.ttl)
|
|
300
|
+
elif args.cms_command == "preset-save":
|
|
301
|
+
sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
|
|
302
|
+
result = store.save_section_preset(args.project_id, args.preset_id, args.name, sections, args.actor, args.description)
|
|
303
|
+
elif args.cms_command == "preset-list":
|
|
304
|
+
result = store.list_section_presets(args.project_id)
|
|
305
|
+
elif args.cms_command == "preset-insert":
|
|
306
|
+
result = store.insert_section_preset(args.project_id, args.preset_id, args.existing_id, args.actor, args.reason)
|
|
299
307
|
else:
|
|
300
308
|
raise ValueError(f"unknown CMS command: {args.cms_command}")
|
|
301
309
|
emit(result)
|
|
@@ -310,6 +318,25 @@ def command_ui(args: argparse.Namespace) -> int:
|
|
|
310
318
|
return 0 if result["passed"] else 1
|
|
311
319
|
|
|
312
320
|
|
|
321
|
+
def command_sections(args: argparse.Namespace) -> int:
|
|
322
|
+
registry = json.loads(Path(args.registry).resolve().read_text(encoding="utf-8"))
|
|
323
|
+
checked = validate_registry(registry)
|
|
324
|
+
if not checked["passed"]:
|
|
325
|
+
emit(checked)
|
|
326
|
+
return 1
|
|
327
|
+
if args.sections_command == "prompt":
|
|
328
|
+
print(catalogue(registry))
|
|
329
|
+
return 0
|
|
330
|
+
if args.sections_command == "validate":
|
|
331
|
+
sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
|
|
332
|
+
result = {**checked, "copy": copy_notes(registry, sections)}
|
|
333
|
+
emit(result)
|
|
334
|
+
return 0 if result["copy"]["passed"] else 1
|
|
335
|
+
sections = json.loads(Path(args.sections_file).resolve().read_text(encoding="utf-8"))
|
|
336
|
+
emit(section_id_migration(args.page_id, sections))
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
313
340
|
def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
|
|
314
341
|
return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
|
|
315
342
|
|
|
@@ -399,6 +426,12 @@ def parser() -> argparse.ArgumentParser:
|
|
|
399
426
|
redirect.add_argument("--project", default="."); redirect.add_argument("--project-id", default="local-project"); redirect.add_argument("--from-path", required=True); redirect.add_argument("--to-path", required=True); redirect.add_argument("--status-code", type=int, default=301); redirect.add_argument("--actor", default="cli"); redirect.add_argument("--reason", required=True); redirect.add_argument("--confirm", action="store_true")
|
|
400
427
|
preview = cms_sub.add_parser("preview")
|
|
401
428
|
preview.add_argument("--project", default="."); preview.add_argument("--project-id", default="local-project"); preview.add_argument("--document-id", required=True); preview.add_argument("--secret", required=True); preview.add_argument("--ttl", type=int, default=900); preview.add_argument("--confirm", action="store_true")
|
|
429
|
+
preset_save = cms_sub.add_parser("preset-save", help="save a reusable ordered section arrangement")
|
|
430
|
+
preset_save.add_argument("--project", default="."); preset_save.add_argument("--project-id", default="local-project"); preset_save.add_argument("--preset-id", required=True); preset_save.add_argument("--name", required=True); preset_save.add_argument("--sections-file", required=True); preset_save.add_argument("--description", default=""); preset_save.add_argument("--actor", default="cli"); preset_save.add_argument("--confirm", action="store_true")
|
|
431
|
+
preset_list = cms_sub.add_parser("preset-list", help="list reusable section arrangements")
|
|
432
|
+
preset_list.add_argument("--project", default="."); preset_list.add_argument("--project-id", default="local-project"); preset_list.add_argument("--confirm", action="store_true")
|
|
433
|
+
preset_insert = cms_sub.add_parser("preset-insert", help="expand an arrangement with fresh section IDs")
|
|
434
|
+
preset_insert.add_argument("--project", default="."); preset_insert.add_argument("--project-id", default="local-project"); preset_insert.add_argument("--preset-id", required=True); preset_insert.add_argument("--existing-id", action="append", default=[]); preset_insert.add_argument("--actor", default="cli"); preset_insert.add_argument("--reason", required=True); preset_insert.add_argument("--confirm", action="store_true")
|
|
402
435
|
cms.set_defaults(func=command_cms)
|
|
403
436
|
ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
|
|
404
437
|
ui_sub = ui.add_subparsers(dest="ui_command", required=True)
|
|
@@ -406,6 +439,17 @@ def parser() -> argparse.ArgumentParser:
|
|
|
406
439
|
ui_validate.add_argument("--contract", required=True)
|
|
407
440
|
ui_validate.add_argument("--source", action="append", default=[])
|
|
408
441
|
ui_validate.set_defaults(func=command_ui)
|
|
442
|
+
sections = sub.add_parser("sections", help="validate section registry and stable section identities")
|
|
443
|
+
sections_sub = sections.add_subparsers(dest="sections_command", required=True)
|
|
444
|
+
sections_validate = sections_sub.add_parser("validate")
|
|
445
|
+
sections_validate.add_argument("--registry", required=True); sections_validate.add_argument("--sections-file", required=True)
|
|
446
|
+
sections_validate.set_defaults(func=command_sections)
|
|
447
|
+
sections_prompt = sections_sub.add_parser("prompt")
|
|
448
|
+
sections_prompt.add_argument("--registry", required=True)
|
|
449
|
+
sections_prompt.set_defaults(func=command_sections)
|
|
450
|
+
sections_keys = sections_sub.add_parser("keys")
|
|
451
|
+
sections_keys.add_argument("--registry", required=True); sections_keys.add_argument("--sections-file", required=True); sections_keys.add_argument("--page-id", required=True)
|
|
452
|
+
sections_keys.set_defaults(func=command_sections)
|
|
409
453
|
variant = sub.add_parser("variant", help="manage service variant lifecycle")
|
|
410
454
|
variant_sub = variant.add_subparsers(dest="variant_command", required=True)
|
|
411
455
|
create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
|
|
@@ -219,6 +219,54 @@ WantedBy=multi-user.target
|
|
|
219
219
|
(output_dir / f"{domain}.conf").write_text(nginx, encoding="utf-8")
|
|
220
220
|
|
|
221
221
|
|
|
222
|
+
def release_runner(plan: dict) -> str:
|
|
223
|
+
"""Render the ordered, reviewable VPS runner for one release."""
|
|
224
|
+
root = plan["release_root"]
|
|
225
|
+
service = plan["service"]
|
|
226
|
+
domain = plan["domain"]
|
|
227
|
+
current = plan["current_release"]
|
|
228
|
+
return f'''#!/usr/bin/env bash
|
|
229
|
+
set -euo pipefail
|
|
230
|
+
|
|
231
|
+
# Migration precedes the symlink flip; later releases carry agent state.
|
|
232
|
+
: "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
|
|
233
|
+
RELEASE_ROOT="{root}"
|
|
234
|
+
CURRENT="{current}"
|
|
235
|
+
RELEASE="$RELEASE_ROOT/releases/$RELEASE_ID"
|
|
236
|
+
PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
|
237
|
+
mkdir -p "$RELEASE"
|
|
238
|
+
cd "$RELEASE"
|
|
239
|
+
|
|
240
|
+
npm ci
|
|
241
|
+
npm run typecheck
|
|
242
|
+
npm run migrate
|
|
243
|
+
npm run build
|
|
244
|
+
|
|
245
|
+
if [ -d "$CURRENT/.agents" ]; then cp -a "$CURRENT/.agents" "$RELEASE/.agents"; fi
|
|
246
|
+
if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude"; fi
|
|
247
|
+
|
|
248
|
+
ln -sfn "$RELEASE" "$CURRENT"
|
|
249
|
+
systemctl restart "{service}"
|
|
250
|
+
systemctl is-active --quiet "{service}"
|
|
251
|
+
curl -fsS "https://{domain}/robots.txt" >/dev/null
|
|
252
|
+
curl -fsS "https://{domain}/sitemap.xml" >/dev/null
|
|
253
|
+
|
|
254
|
+
# Keep current plus one rollback candidate, and prune only after health checks.
|
|
255
|
+
mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
|
|
256
|
+
for candidate in "${{RELEASES[@]:2}}"; do
|
|
257
|
+
[ "$candidate" = "$PREVIOUS" ] && continue
|
|
258
|
+
[ "$candidate" = "$RELEASE" ] && continue
|
|
259
|
+
rm -rf -- "$candidate"
|
|
260
|
+
done
|
|
261
|
+
'''
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def write_release_runner(path: Path, plan: dict) -> None:
|
|
265
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
path.write_text(release_runner(plan), encoding="utf-8")
|
|
267
|
+
path.chmod(0o750)
|
|
268
|
+
|
|
269
|
+
|
|
222
270
|
def main() -> int:
|
|
223
271
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
224
272
|
parser.add_argument("project", nargs="?", default=".")
|
|
@@ -231,6 +279,7 @@ def main() -> int:
|
|
|
231
279
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
232
280
|
parser.add_argument("--node-port", type=int, default=4321)
|
|
233
281
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
282
|
+
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
234
283
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
235
284
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
236
285
|
parser.add_argument("--keep-releases", type=int, default=2)
|
|
@@ -249,6 +298,8 @@ def main() -> int:
|
|
|
249
298
|
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port)
|
|
250
299
|
if args.plan_dir:
|
|
251
300
|
write_vps_plan(Path(args.plan_dir).resolve(), plan)
|
|
301
|
+
if args.runner_output:
|
|
302
|
+
write_release_runner(Path(args.runner_output).resolve(), plan)
|
|
252
303
|
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
253
304
|
return 0
|
|
254
305
|
if args.retention_plan:
|
|
@@ -20,6 +20,9 @@ from urllib.request import Request, urlopen
|
|
|
20
20
|
|
|
21
21
|
DEFAULT_ENDPOINT = "https://feedback.noblox.app/api/feedback"
|
|
22
22
|
SECRET_RE = re.compile(r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|npm_config[^=]*|authorization)\s*[=:]\s*)[^\s,;]+")
|
|
23
|
+
VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
|
|
24
|
+
ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
def now() -> str:
|
|
@@ -35,17 +38,29 @@ def project_fingerprint(project: Path) -> str:
|
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
def installed_version(project: Path) -> str:
|
|
38
|
-
"""Resolve the
|
|
41
|
+
"""Resolve the exact runtime version and fail closed on source drift."""
|
|
39
42
|
if os.environ.get("MAGGIE_VERSION"):
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
value = safe_text(os.environ["MAGGIE_VERSION"])
|
|
44
|
+
return value if VERSION_RE.fullmatch(value) else "unknown"
|
|
45
|
+
install_path = project / ".maggie" / "install.json"
|
|
46
|
+
try:
|
|
47
|
+
value = json.loads(install_path.read_text(encoding="utf-8")).get("version", "")
|
|
48
|
+
if VERSION_RE.fullmatch(str(value)):
|
|
49
|
+
return str(value)
|
|
50
|
+
except (OSError, ValueError, json.JSONDecodeError, AttributeError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
source_versions = []
|
|
54
|
+
for path in (REPO_ROOT / "VERSION", REPO_ROOT / "packages" / "maggie-cli" / "package.json"):
|
|
42
55
|
try:
|
|
43
56
|
value = json.loads(path.read_text(encoding="utf-8")) if path.suffix == ".json" else path.read_text(encoding="utf-8").strip()
|
|
44
57
|
if isinstance(value, dict): value = value.get("version", "")
|
|
45
|
-
if
|
|
46
|
-
|
|
58
|
+
if VERSION_RE.fullmatch(str(value)):
|
|
59
|
+
source_versions.append(str(value))
|
|
47
60
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
48
61
|
continue
|
|
62
|
+
if source_versions and len(set(source_versions)) == 1:
|
|
63
|
+
return source_versions[0]
|
|
49
64
|
return "unknown"
|
|
50
65
|
|
|
51
66
|
|
|
@@ -124,6 +139,18 @@ def to_markdown(data: dict) -> str:
|
|
|
124
139
|
return "\n".join(lines)
|
|
125
140
|
|
|
126
141
|
|
|
142
|
+
def acknowledgement(value: object) -> dict:
|
|
143
|
+
"""Keep only safe, scalar acknowledgement fields from a provider response."""
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
return {}
|
|
146
|
+
result = {}
|
|
147
|
+
for key in ACKNOWLEDGEMENT_KEYS:
|
|
148
|
+
item = value.get(key)
|
|
149
|
+
if isinstance(item, (str, int, float, bool)):
|
|
150
|
+
result[key] = safe_text(item)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
127
154
|
def read_feedback(path: Path) -> dict:
|
|
128
155
|
return load_json(path)
|
|
129
156
|
|
|
@@ -152,9 +179,10 @@ def submit(args: argparse.Namespace) -> int:
|
|
|
152
179
|
result = json.loads(raw) if raw else {}
|
|
153
180
|
except (HTTPError, URLError, TimeoutError) as error:
|
|
154
181
|
raise RuntimeError(f"feedback submission failed: {error}") from error
|
|
155
|
-
|
|
182
|
+
ack = acknowledgement(result)
|
|
183
|
+
data["submission"] = {"status": "submitted", "endpoint": endpoint, "submittedAt": now(), "acknowledgement": ack}
|
|
156
184
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
157
|
-
print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "
|
|
185
|
+
print(json.dumps({"feedbackId": data["feedbackId"], "status": "submitted", "acknowledgement": ack}, indent=2, ensure_ascii=False))
|
|
158
186
|
return 0
|
|
159
187
|
|
|
160
188
|
|
|
@@ -12,6 +12,8 @@ from pathlib import Path
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
13
13
|
from dependency_lock import audit as audit_lockfiles
|
|
14
14
|
from seed_evidence import validate_manifest
|
|
15
|
+
from agent_runtime import preflight as agent_preflight
|
|
16
|
+
from restart_gate import validate as validate_restart_gate
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
REQUIRED_ROUTES = (
|
|
@@ -149,6 +151,20 @@ def command_seed_manifest(args: argparse.Namespace) -> int:
|
|
|
149
151
|
return 0 if result["passed"] else 1
|
|
150
152
|
|
|
151
153
|
|
|
154
|
+
def command_agent_preflight(args: argparse.Namespace) -> int:
|
|
155
|
+
result = agent_preflight(release_dir=args.release_dir)
|
|
156
|
+
if args.output:
|
|
157
|
+
write_json(Path(args.output).resolve(), result)
|
|
158
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def command_restart_gate(args: argparse.Namespace) -> int:
|
|
163
|
+
result = validate_restart_gate(read_json(Path(args.manifest).resolve()))
|
|
164
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
165
|
+
return 0 if result["passed"] else 1
|
|
166
|
+
|
|
167
|
+
|
|
152
168
|
def main() -> int:
|
|
153
169
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
154
170
|
parser.add_argument("--project", default=".")
|
|
@@ -160,6 +176,13 @@ def main() -> int:
|
|
|
160
176
|
seed = sub.add_parser("seed-manifest", help="validate explicit sanitized development fixtures")
|
|
161
177
|
seed.add_argument("--manifest", required=True)
|
|
162
178
|
seed.set_defaults(func=command_seed_manifest)
|
|
179
|
+
agent = sub.add_parser("agent-preflight", help="report agent runtime and deployment gotchas")
|
|
180
|
+
agent.add_argument("--output")
|
|
181
|
+
agent.add_argument("--release-dir")
|
|
182
|
+
agent.set_defaults(func=command_agent_preflight)
|
|
183
|
+
restart = sub.add_parser("restart-gate", help="validate restart-after-out-of-band-write evidence")
|
|
184
|
+
restart.add_argument("--manifest", required=True)
|
|
185
|
+
restart.set_defaults(func=command_restart_gate)
|
|
163
186
|
record = sub.add_parser("record", help="record an explicitly authorised operation")
|
|
164
187
|
record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
|
|
165
188
|
record.add_argument("--dry-run", action="store_true")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate changed route surfaces and locales from explicit evidence."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
12
|
+
from surface_coverage import compare # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
17
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
coverage = sub.add_parser("coverage")
|
|
19
|
+
coverage.add_argument("--contract", type=Path, required=True)
|
|
20
|
+
coverage.add_argument("--evidence", type=Path, required=True)
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
if args.command == "coverage":
|
|
23
|
+
contract = json.loads(args.contract.resolve().read_text(encoding="utf-8"))
|
|
24
|
+
evidence = json.loads(args.evidence.resolve().read_text(encoding="utf-8"))
|
|
25
|
+
observations = evidence.get("observations") if isinstance(evidence, dict) else evidence
|
|
26
|
+
result = compare(contract, observations)
|
|
27
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
28
|
+
return 0 if result["passed"] else 1
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Operational checks for running agent CLIs from a deployed workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ANSI = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def strip_ansi(value: str) -> str:
|
|
14
|
+
return ANSI.sub("", value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def preflight(environment: dict[str, str] | None = None, release_dir: str | None = None) -> dict:
|
|
18
|
+
env = environment or dict(os.environ)
|
|
19
|
+
warnings = [
|
|
20
|
+
"strip ANSI terminal escapes before parsing URLs, codes, or stored transcripts",
|
|
21
|
+
"use device authentication on a headless host and confirm the account prerequisite before launch",
|
|
22
|
+
"carry agent installs and conversation state outside immutable release directories",
|
|
23
|
+
"put the resume subcommand before agent flags so the CLI cannot treat it as a new prompt",
|
|
24
|
+
]
|
|
25
|
+
risks = []
|
|
26
|
+
if env.get("NO_COLOR") != "1" or env.get("TERM") not in {"dumb", ""}:
|
|
27
|
+
risks.append("terminal color may be enabled; parser must still strip ANSI")
|
|
28
|
+
if release_dir and Path(release_dir).name in {"current", "releases"}:
|
|
29
|
+
risks.append("agent state must not be stored only inside an immutable release")
|
|
30
|
+
return {
|
|
31
|
+
"schemaVersion": "maggie-agent-runtime.v1",
|
|
32
|
+
"passed": True,
|
|
33
|
+
"warnings": warnings,
|
|
34
|
+
"risks": risks,
|
|
35
|
+
"auth": "device",
|
|
36
|
+
"resume": {"codex": ["resume", "--last"], "claude": ["--continue"]},
|
|
37
|
+
"statePolicy": "outside-release-directory",
|
|
38
|
+
}
|
|
@@ -13,6 +13,8 @@ from datetime import datetime, timedelta, timezone
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
from maggie_sections import ensure_section_ids
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
STATUSES = ("draft", "review", "approved", "scheduled", "published", "archived", "trashed")
|
|
18
20
|
TRANSITIONS = {
|
|
@@ -83,6 +85,12 @@ class MaggieDashStore:
|
|
|
83
85
|
actor_id TEXT NOT NULL, reason TEXT NOT NULL, correlation_id TEXT NOT NULL,
|
|
84
86
|
created_at TEXT NOT NULL
|
|
85
87
|
);
|
|
88
|
+
CREATE TABLE IF NOT EXISTS maggiedash_section_presets (
|
|
89
|
+
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES maggiedash_projects(id),
|
|
90
|
+
name TEXT NOT NULL, description TEXT, body_json TEXT NOT NULL,
|
|
91
|
+
created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
92
|
+
UNIQUE(project_id, name)
|
|
93
|
+
);
|
|
86
94
|
"""
|
|
87
95
|
)
|
|
88
96
|
self._ensure_column("maggiedash_documents", "trashed_at", "TEXT")
|
|
@@ -268,6 +276,49 @@ class MaggieDashStore:
|
|
|
268
276
|
self.connection.commit()
|
|
269
277
|
return result
|
|
270
278
|
|
|
279
|
+
def save_section_preset(self, project_id: str, preset_id: str, name: str, sections: list[dict[str, Any]], actor_id: str, description: str = "") -> dict[str, Any]:
|
|
280
|
+
"""Save an ordered arrangement without leaking page-specific IDs."""
|
|
281
|
+
name = name.strip()
|
|
282
|
+
if not name:
|
|
283
|
+
raise ValueError("preset name is required")
|
|
284
|
+
if not isinstance(sections, list) or not sections or not all(isinstance(section, dict) and section.get("type") for section in sections):
|
|
285
|
+
raise ValueError("preset sections must be a non-empty typed list")
|
|
286
|
+
body = []
|
|
287
|
+
for section in sections:
|
|
288
|
+
copy = json.loads(json.dumps(section, ensure_ascii=False))
|
|
289
|
+
copy.pop("id", None)
|
|
290
|
+
body.append(copy)
|
|
291
|
+
timestamp = now()
|
|
292
|
+
self.connection.execute(
|
|
293
|
+
"INSERT INTO maggiedash_section_presets (id,project_id,name,description,body_json,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) "
|
|
294
|
+
"ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, body_json=excluded.body_json, updated_at=excluded.updated_at",
|
|
295
|
+
(preset_id, project_id, name, description.strip(), json.dumps(body, ensure_ascii=False), actor_id, timestamp, timestamp),
|
|
296
|
+
)
|
|
297
|
+
self._audit(project_id, "section-preset.saved", "section_preset", preset_id, actor_id, "ordered arrangement saved")
|
|
298
|
+
self.connection.commit()
|
|
299
|
+
return self.get_section_preset(project_id, preset_id) or {}
|
|
300
|
+
|
|
301
|
+
def get_section_preset(self, project_id: str, preset_id: str) -> dict[str, Any] | None:
|
|
302
|
+
row = self.connection.execute("SELECT * FROM maggiedash_section_presets WHERE project_id=? AND id=?", (project_id, preset_id)).fetchone()
|
|
303
|
+
if not row:
|
|
304
|
+
return None
|
|
305
|
+
result = dict(row)
|
|
306
|
+
result["body"] = json.loads(result.pop("body_json"))
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
def list_section_presets(self, project_id: str) -> list[dict[str, Any]]:
|
|
310
|
+
rows = self.connection.execute("SELECT id FROM maggiedash_section_presets WHERE project_id=? ORDER BY updated_at DESC", (project_id,)).fetchall()
|
|
311
|
+
return [self.get_section_preset(project_id, row["id"]) for row in rows]
|
|
312
|
+
|
|
313
|
+
def insert_section_preset(self, project_id: str, preset_id: str, existing_ids: list[str], actor_id: str, reason: str) -> list[dict[str, Any]]:
|
|
314
|
+
preset = self.get_section_preset(project_id, preset_id)
|
|
315
|
+
if not preset:
|
|
316
|
+
raise ValueError("section preset not found")
|
|
317
|
+
result = ensure_section_ids(preset["body"], existing_ids)
|
|
318
|
+
self._audit(project_id, "section-preset.inserted", "section_preset", preset_id, actor_id, reason)
|
|
319
|
+
self.connection.commit()
|
|
320
|
+
return result
|
|
321
|
+
|
|
271
322
|
def add_redirect(self, project_id: str, from_path: str, to_path: str, actor_id: str, reason: str, status_code: int = 301) -> dict[str, Any]:
|
|
272
323
|
if not from_path.startswith("/") or not to_path.startswith("/") or self._normalise_path(from_path) == self._normalise_path(to_path):
|
|
273
324
|
raise ValueError("redirect paths must be distinct absolute paths")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Provider-neutral section registry, copy limits, and stable section keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCHEMA = "maggiedash-section-registry.v1"
|
|
14
|
+
IDENTITY_SCHEMA = "maggiedash-section-identity.v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_registry(value: object) -> dict[str, Any]:
|
|
18
|
+
errors: list[str] = []
|
|
19
|
+
if not isinstance(value, dict):
|
|
20
|
+
return {"schemaVersion": SCHEMA, "passed": False, "errors": ["registry must be an object"]}
|
|
21
|
+
if value.get("schemaVersion") != SCHEMA:
|
|
22
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
23
|
+
sections = value.get("sections")
|
|
24
|
+
if not isinstance(sections, list) or not sections:
|
|
25
|
+
errors.append("sections must be a non-empty list")
|
|
26
|
+
sections = []
|
|
27
|
+
seen: set[str] = set()
|
|
28
|
+
for index, section in enumerate(sections):
|
|
29
|
+
at = f"sections[{index}]"
|
|
30
|
+
if not isinstance(section, dict):
|
|
31
|
+
errors.append(f"{at} must be an object")
|
|
32
|
+
continue
|
|
33
|
+
section_type = str(section.get("type") or "")
|
|
34
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", section_type):
|
|
35
|
+
errors.append(f"{at}.type must be a kebab-case identifier")
|
|
36
|
+
if section_type in seen:
|
|
37
|
+
errors.append(f"duplicate section type: {section_type}")
|
|
38
|
+
seen.add(section_type)
|
|
39
|
+
for field in ("purpose", "usage", "position", "repeatable"):
|
|
40
|
+
if field not in section:
|
|
41
|
+
errors.append(f"{at}.{field} is required")
|
|
42
|
+
if section.get("position") not in {"opening", "body", "closing"}:
|
|
43
|
+
errors.append(f"{at}.position must be opening, body, or closing")
|
|
44
|
+
if not isinstance(section.get("repeatable"), bool):
|
|
45
|
+
errors.append(f"{at}.repeatable must be boolean")
|
|
46
|
+
fields = section.get("fields")
|
|
47
|
+
if not isinstance(fields, list) or not fields:
|
|
48
|
+
errors.append(f"{at}.fields must be a non-empty list")
|
|
49
|
+
continue
|
|
50
|
+
field_names: set[str] = set()
|
|
51
|
+
for field_index, field in enumerate(fields):
|
|
52
|
+
field_at = f"{at}.fields[{field_index}]"
|
|
53
|
+
if not isinstance(field, dict) or not field.get("name"):
|
|
54
|
+
errors.append(f"{field_at} needs a name")
|
|
55
|
+
continue
|
|
56
|
+
name = str(field["name"])
|
|
57
|
+
if name in field_names:
|
|
58
|
+
errors.append(f"duplicate field: {section_type}.{name}")
|
|
59
|
+
field_names.add(name)
|
|
60
|
+
if not isinstance(field.get("limit"), int) or field["limit"] < 0:
|
|
61
|
+
errors.append(f"{field_at}.limit must be a non-negative integer")
|
|
62
|
+
if not str(field.get("usage") or "").strip():
|
|
63
|
+
errors.append(f"{field_at}.usage is required")
|
|
64
|
+
repeats = field.get("repeats")
|
|
65
|
+
if repeats is not None and (not isinstance(repeats, dict) or not isinstance(repeats.get("min"), int) or not isinstance(repeats.get("max"), int) or repeats["min"] < 0 or repeats["min"] > repeats["max"]):
|
|
66
|
+
errors.append(f"{field_at}.repeats must declare valid min/max")
|
|
67
|
+
return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "sectionTypes": sorted(seen)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def catalogue(registry: dict[str, Any]) -> str:
|
|
71
|
+
"""Render a compact prompt catalogue without duplicating field guidance."""
|
|
72
|
+
lines: list[str] = []
|
|
73
|
+
for section in registry.get("sections", []):
|
|
74
|
+
lines.append(f"{section['type']} — {section['purpose']} | when: {section['usage']} | place: {section['position']} | {'repeatable' if section['repeatable'] else 'once'}")
|
|
75
|
+
for field in section.get("fields", []):
|
|
76
|
+
repeat = field.get("repeats")
|
|
77
|
+
suffix = f" [{repeat['min']}-{repeat['max']} entries]" if isinstance(repeat, dict) else ""
|
|
78
|
+
lines.append(f" - {field['name']} ≤{field['limit']} chars{suffix}: {field['usage']}")
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def copy_notes(registry: dict[str, Any], sections: object) -> dict[str, Any]:
|
|
83
|
+
"""Return advisory copy/shape notes; over-limit copy is not rejected."""
|
|
84
|
+
errors: list[str] = []
|
|
85
|
+
notes: list[dict[str, Any]] = []
|
|
86
|
+
if not isinstance(sections, list):
|
|
87
|
+
return {"passed": False, "errors": ["sections must be a list"], "notes": []}
|
|
88
|
+
specs = {str(item.get("type")): item for item in registry.get("sections", []) if isinstance(item, dict)}
|
|
89
|
+
for index, section in enumerate(sections):
|
|
90
|
+
if not isinstance(section, dict):
|
|
91
|
+
errors.append(f"sections[{index}] must be an object")
|
|
92
|
+
continue
|
|
93
|
+
section_type = str(section.get("type") or "")
|
|
94
|
+
spec = specs.get(section_type)
|
|
95
|
+
if not spec:
|
|
96
|
+
errors.append(f"unknown section type: {section_type}")
|
|
97
|
+
continue
|
|
98
|
+
for field in spec.get("fields", []):
|
|
99
|
+
name = str(field.get("name"))
|
|
100
|
+
value = section.get(name)
|
|
101
|
+
limit = int(field.get("limit", 0))
|
|
102
|
+
if isinstance(value, str) and limit and len(value) > limit:
|
|
103
|
+
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} characters; layout limit is about {limit}"})
|
|
104
|
+
repeats = field.get("repeats")
|
|
105
|
+
if isinstance(repeats, dict) and isinstance(value, list) and not repeats["min"] <= len(value) <= repeats["max"]:
|
|
106
|
+
notes.append({"at": f"sections[{index}].{name}", "message": f"{len(value)} entries; layout expects {repeats['min']}-{repeats['max']}"})
|
|
107
|
+
return {"passed": not errors, "errors": errors, "notes": notes}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def new_section_id(existing: Iterable[str] = ()) -> str:
|
|
111
|
+
taken = set(existing)
|
|
112
|
+
while True:
|
|
113
|
+
candidate = uuid.uuid4().hex[:8]
|
|
114
|
+
if candidate not in taken:
|
|
115
|
+
return candidate
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ensure_section_ids(sections: list[dict[str, Any]], existing: Iterable[str] = ()) -> list[dict[str, Any]]:
|
|
119
|
+
result = copy.deepcopy(sections)
|
|
120
|
+
existing_ids = set(existing)
|
|
121
|
+
taken = set(existing_ids)
|
|
122
|
+
for section in result:
|
|
123
|
+
current = str(section.get("id") or "")
|
|
124
|
+
if not current or current in existing_ids or current in taken:
|
|
125
|
+
section["id"] = new_section_id(taken)
|
|
126
|
+
taken.add(str(section["id"]))
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def section_key(section: object, index: int) -> str:
|
|
131
|
+
if isinstance(section, dict) and isinstance(section.get("id"), str) and section["id"]:
|
|
132
|
+
return section["id"]
|
|
133
|
+
return str(index)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def translation_key(page_id: str, section: object, index: int, field: str) -> str:
|
|
137
|
+
return f"page.{page_id}.sections.{section_key(section, index)}.{field}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def section_id_migration(page_id: str, sections: list[dict[str, Any]]) -> dict[str, Any]:
|
|
141
|
+
identified = ensure_section_ids(sections)
|
|
142
|
+
mappings = [{"from": f"page.{page_id}.sections.{index}.", "to": f"page.{page_id}.sections.{section['id']}."} for index, section in enumerate(identified)]
|
|
143
|
+
return {"schemaVersion": IDENTITY_SCHEMA, "pageId": page_id, "sections": identified, "renameOrder": sorted(mappings, key=lambda item: len(item["from"]), reverse=True), "sourceChecksum": hashlib.sha256(json.dumps(sections, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest()}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Evidence gate for writes performed outside the running application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate(value: object) -> dict[str, Any]:
|
|
9
|
+
errors: list[str] = []
|
|
10
|
+
if not isinstance(value, dict):
|
|
11
|
+
return {"schemaVersion": "maggie-restart-gate.v1", "passed": False, "errors": ["restart gate must be an object"]}
|
|
12
|
+
if value.get("schemaVersion") != "maggie-restart-gate.v1":
|
|
13
|
+
errors.append("unsupported restart gate schema")
|
|
14
|
+
if value.get("outOfBandWrite") is not True:
|
|
15
|
+
errors.append("outOfBandWrite must be true for this gate")
|
|
16
|
+
if value.get("restartRequired") is not True:
|
|
17
|
+
errors.append("restartRequired must be true after an out-of-band write")
|
|
18
|
+
if not str(value.get("restartCommand") or "").strip():
|
|
19
|
+
errors.append("restartCommand is required")
|
|
20
|
+
if value.get("verificationAfterRestart") is not True:
|
|
21
|
+
errors.append("verificationAfterRestart must be true")
|
|
22
|
+
return {"schemaVersion": "maggie-restart-gate.v1", "passed": not errors, "errors": errors, "order": ["write", "restart", "verify"]}
|