@topy-ai/maggie 0.7.3 → 0.7.5
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 +29 -9
- 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 +22 -0
- package/bundled-contracts/maggiedash/agent-content-v1.json +15 -0
- package/bundled-contracts/maggiedash/redirect-policy-v1.json +15 -0
- package/bundled-contracts/maggiedash/section-identity-v1.json +14 -0
- package/bundled-contracts/maggiedash/telemetry-policy-v1.json +18 -0
- package/bundled-contracts/maggiedash/translation-cache-policy-v1.json +15 -0
- package/bundled-references/maggiedash-telemetry-contract.md +12 -0
- package/bundled-skills/maggie-dash/SKILL.md +76 -3
- package/bundled-skills/maggie-deployment/SKILL.md +20 -2
- package/bundled-skills/maggie-ops/SKILL.md +23 -2
- package/bundled-skills/maggie-seo-geo/SKILL.md +16 -0
- package/bundled-templates/maggiedash/dashboard-ui-contract.json +8 -7
- package/bundled-templates/maggiedash/section-registry.json +12 -0
- package/bundled-tools/clis/maggie_agent_content.py +75 -0
- package/bundled-tools/clis/maggie_dash.py +112 -1
- package/bundled-tools/clis/maggie_deployment.py +51 -0
- 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/analytics_traffic.py +6 -1
- package/bundled-tools/runtime/maggie_dash_store.py +121 -8
- package/bundled-tools/runtime/maggie_dash_ui.py +35 -1
- 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 +48 -0
- package/package.json +1 -1
- package/references/maggiedash-telemetry-contract.md +12 -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:
|
|
@@ -77,8 +78,59 @@ def copy_dashboard_tree(source: Path, target: Path, force: bool) -> tuple[list[s
|
|
|
77
78
|
return installed, preserved
|
|
78
79
|
|
|
79
80
|
|
|
81
|
+
def manifest_file_pairs(source: Path, manifest: dict[str, object], target: Path) -> list[tuple[Path, Path, Path]]:
|
|
82
|
+
"""Return source, install-target, and manifest-relative file paths."""
|
|
83
|
+
pairs: list[tuple[Path, Path, Path]] = []
|
|
84
|
+
for item in manifest.get("files", []):
|
|
85
|
+
relative = Path(str(item))
|
|
86
|
+
source_item = source / relative
|
|
87
|
+
if source_item.is_file():
|
|
88
|
+
relative_target = Path(*relative.parts[1:]) if relative.parts and relative.parts[0] == "admin" else relative
|
|
89
|
+
pairs.append((source_item, target / relative_target, relative))
|
|
90
|
+
continue
|
|
91
|
+
for path in sorted(source_item.rglob("*")):
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
continue
|
|
94
|
+
child = path.relative_to(source_item)
|
|
95
|
+
relative_target = Path(*relative.parts[1:]) / child if relative.parts and relative.parts[0] == "admin" else relative / child
|
|
96
|
+
pairs.append((path, target / relative_target, relative / child))
|
|
97
|
+
return pairs
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool) -> dict[str, object]:
|
|
101
|
+
pairs = manifest_file_pairs(source, manifest, target)
|
|
102
|
+
files: list[dict[str, str]] = []
|
|
103
|
+
source_relative = {relative for _, _, relative in pairs}
|
|
104
|
+
for source_file, destination, relative in pairs:
|
|
105
|
+
if compare_dir:
|
|
106
|
+
compare_relative = Path(*relative.parts[2:]) if relative.parts[:2] == ("admin", "src") else (Path(*relative.parts[1:]) if relative.parts and relative.parts[0] == "admin" else relative)
|
|
107
|
+
compare_file = compare_dir / compare_relative
|
|
108
|
+
else:
|
|
109
|
+
compare_file = destination
|
|
110
|
+
if not compare_file.exists():
|
|
111
|
+
action = "add"
|
|
112
|
+
elif compare_file.read_bytes() == source_file.read_bytes():
|
|
113
|
+
action = "unchanged"
|
|
114
|
+
else:
|
|
115
|
+
action = "update" if force else "preserve"
|
|
116
|
+
files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
|
|
117
|
+
if compare_dir and compare_dir.exists():
|
|
118
|
+
for existing in sorted(compare_dir.rglob("*")):
|
|
119
|
+
if not existing.is_file():
|
|
120
|
+
continue
|
|
121
|
+
relative = existing.relative_to(compare_dir)
|
|
122
|
+
manifest_relative = Path("admin", "src", *relative.parts)
|
|
123
|
+
if manifest_relative not in source_relative:
|
|
124
|
+
files.append({"path": str(manifest_relative), "action": "unmanaged-existing", "comparePath": str(existing)})
|
|
125
|
+
summary: dict[str, int] = {}
|
|
126
|
+
for item in files:
|
|
127
|
+
summary[item["action"]] = summary.get(item["action"], 0) + 1
|
|
128
|
+
return {"status": "dry-run", "version": manifest.get("version"), "target": str(target), "compareDir": str(compare_dir) if compare_dir else None, "summary": summary, "files": files}
|
|
129
|
+
|
|
130
|
+
|
|
80
131
|
def command_install(args: argparse.Namespace) -> int:
|
|
81
|
-
|
|
132
|
+
if not args.dry_run and not args.diff:
|
|
133
|
+
require_confirm(args)
|
|
82
134
|
root = project_root(args)
|
|
83
135
|
source_value = args.source or os.environ.get("MAGGIE_DASH_SOURCE") or DEFAULT_DASH_SOURCE
|
|
84
136
|
ref = args.ref
|
|
@@ -112,6 +164,15 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
112
164
|
raise RuntimeError("unsupported MaggieDash distribution schema")
|
|
113
165
|
if manifest.get("target") != "_maggie/admin" and args.target == "_maggie/admin":
|
|
114
166
|
raise RuntimeError("MaggieDash manifest target is not ./_maggie/admin")
|
|
167
|
+
compare_dir = None
|
|
168
|
+
if args.existing_dir:
|
|
169
|
+
compare_dir = Path(args.existing_dir).expanduser()
|
|
170
|
+
if not compare_dir.is_absolute():
|
|
171
|
+
compare_dir = root / compare_dir
|
|
172
|
+
compare_dir = compare_dir.resolve()
|
|
173
|
+
if args.dry_run or args.diff:
|
|
174
|
+
emit(command_install_diff(source, manifest, target, compare_dir, args.force))
|
|
175
|
+
return 0
|
|
115
176
|
installed: list[str] = []
|
|
116
177
|
preserved: list[str] = []
|
|
117
178
|
for item in manifest.get("files", []):
|
|
@@ -228,12 +289,21 @@ def command_cms(args: argparse.Namespace) -> int:
|
|
|
228
289
|
result = store.restore(args.project_id, args.document_id, args.actor, args.reason)
|
|
229
290
|
elif args.cms_command == "schedule":
|
|
230
291
|
result = store.schedule_publish(args.project_id, args.document_id, args.publish_at, args.actor, args.reason)
|
|
292
|
+
elif args.cms_command == "publish-due":
|
|
293
|
+
result = store.publish_due(args.project_id, args.now, args.actor, args.reason)
|
|
231
294
|
elif args.cms_command == "duplicate":
|
|
232
295
|
result = store.duplicate(args.project_id, args.document_id, args.new_id, args.new_slug, args.actor, args.reason)
|
|
233
296
|
elif args.cms_command == "redirect":
|
|
234
297
|
result = store.add_redirect(args.project_id, args.from_path, args.to_path, args.actor, args.reason, args.status_code)
|
|
235
298
|
elif args.cms_command == "preview":
|
|
236
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)
|
|
237
307
|
else:
|
|
238
308
|
raise ValueError(f"unknown CMS command: {args.cms_command}")
|
|
239
309
|
emit(result)
|
|
@@ -248,6 +318,25 @@ def command_ui(args: argparse.Namespace) -> int:
|
|
|
248
318
|
return 0 if result["passed"] else 1
|
|
249
319
|
|
|
250
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
|
+
|
|
251
340
|
def variant_store(args: argparse.Namespace) -> ServiceVariantStore:
|
|
252
341
|
return ServiceVariantStore(project_root(args) / ".maggie" / "service-variants.json")
|
|
253
342
|
|
|
@@ -299,6 +388,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
299
388
|
install.add_argument("--ref", default="main", help="Git ref when installing from a remote source")
|
|
300
389
|
install.add_argument("--target", default="_maggie/admin", help="installation target relative to the project")
|
|
301
390
|
install.add_argument("--force", action="store_true", help="replace existing dashboard files")
|
|
391
|
+
install.add_argument("--dry-run", action="store_true", help="show the install plan without writing files")
|
|
392
|
+
install.add_argument("--diff", action="store_true", help="show the install plan and compare with an existing workspace")
|
|
393
|
+
install.add_argument("--existing-dir", help="existing dashboard source directory to compare during --diff")
|
|
302
394
|
install.add_argument("--confirm", action="store_true")
|
|
303
395
|
install.set_defaults(func=command_install)
|
|
304
396
|
status = sub.add_parser("status", help="inspect project and documents")
|
|
@@ -326,12 +418,20 @@ def parser() -> argparse.ArgumentParser:
|
|
|
326
418
|
command.add_argument("--document-id", required=True); command.add_argument("--actor", default="cli"); command.add_argument("--reason", default="CMS operation"); command.add_argument("--confirm", action="store_true")
|
|
327
419
|
schedule = cms_sub.add_parser("schedule")
|
|
328
420
|
schedule.add_argument("--project", default="."); schedule.add_argument("--project-id", default="local-project"); schedule.add_argument("--document-id", required=True); schedule.add_argument("--publish-at", required=True); schedule.add_argument("--actor", default="cli"); schedule.add_argument("--reason", required=True); schedule.add_argument("--confirm", action="store_true")
|
|
421
|
+
publish_due = cms_sub.add_parser("publish-due", help="publish approved scheduled content whose time has arrived")
|
|
422
|
+
publish_due.add_argument("--project", default="."); publish_due.add_argument("--project-id", default="local-project"); publish_due.add_argument("--now"); publish_due.add_argument("--actor", default="scheduler"); publish_due.add_argument("--reason", default="scheduled publish tick"); publish_due.add_argument("--confirm", action="store_true")
|
|
329
423
|
duplicate = cms_sub.add_parser("duplicate")
|
|
330
424
|
duplicate.add_argument("--project", default="."); duplicate.add_argument("--project-id", default="local-project"); duplicate.add_argument("--document-id", required=True); duplicate.add_argument("--new-id", required=True); duplicate.add_argument("--new-slug", required=True); duplicate.add_argument("--actor", default="cli"); duplicate.add_argument("--reason", required=True); duplicate.add_argument("--confirm", action="store_true")
|
|
331
425
|
redirect = cms_sub.add_parser("redirect")
|
|
332
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")
|
|
333
427
|
preview = cms_sub.add_parser("preview")
|
|
334
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")
|
|
335
435
|
cms.set_defaults(func=command_cms)
|
|
336
436
|
ui = sub.add_parser("ui", help="validate MaggieDash dashboard chrome")
|
|
337
437
|
ui_sub = ui.add_subparsers(dest="ui_command", required=True)
|
|
@@ -339,6 +439,17 @@ def parser() -> argparse.ArgumentParser:
|
|
|
339
439
|
ui_validate.add_argument("--contract", required=True)
|
|
340
440
|
ui_validate.add_argument("--source", action="append", default=[])
|
|
341
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)
|
|
342
453
|
variant = sub.add_parser("variant", help="manage service variant lifecycle")
|
|
343
454
|
variant_sub = variant.add_subparsers(dest="variant_command", required=True)
|
|
344
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:
|
|
@@ -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
|
+
}
|
|
@@ -17,13 +17,18 @@ def is_tool_traffic(event: dict) -> bool:
|
|
|
17
17
|
return bool(TOOL_USER_AGENT.search(str(event.get("userAgent") or "")))
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def should_record_measurement(event: dict) -> bool:
|
|
21
|
+
"""Apply the same exclusion before every counter or event logger."""
|
|
22
|
+
return not is_tool_traffic(event)
|
|
23
|
+
|
|
24
|
+
|
|
20
25
|
def audit_events(events: Iterable[dict]) -> dict:
|
|
21
26
|
totals = Counter()
|
|
22
27
|
excluded = Counter()
|
|
23
28
|
included = 0
|
|
24
29
|
for event in events:
|
|
25
30
|
totals[str(event.get("event") or "unknown")] += 1
|
|
26
|
-
if
|
|
31
|
+
if not should_record_measurement(event):
|
|
27
32
|
excluded[str(event.get("event") or "unknown")] += 1
|
|
28
33
|
else:
|
|
29
34
|
included += 1
|
|
@@ -13,8 +13,10 @@ 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
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
|
|
19
|
+
STATUSES = ("draft", "review", "approved", "scheduled", "published", "archived", "trashed")
|
|
18
20
|
TRANSITIONS = {
|
|
19
21
|
"draft": {"review"},
|
|
20
22
|
"review": {"approved", "draft"},
|
|
@@ -22,6 +24,7 @@ TRANSITIONS = {
|
|
|
22
24
|
"scheduled": {"published", "draft"},
|
|
23
25
|
"published": {"archived"},
|
|
24
26
|
"archived": set(),
|
|
27
|
+
"trashed": set(),
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
|
|
@@ -82,6 +85,12 @@ class MaggieDashStore:
|
|
|
82
85
|
actor_id TEXT NOT NULL, reason TEXT NOT NULL, correlation_id TEXT NOT NULL,
|
|
83
86
|
created_at TEXT NOT NULL
|
|
84
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
|
+
);
|
|
85
94
|
"""
|
|
86
95
|
)
|
|
87
96
|
self._ensure_column("maggiedash_documents", "trashed_at", "TEXT")
|
|
@@ -120,8 +129,15 @@ class MaggieDashStore:
|
|
|
120
129
|
status = document.get("status", "draft")
|
|
121
130
|
if status not in STATUSES:
|
|
122
131
|
raise ValueError(f"invalid document status: {status}")
|
|
123
|
-
stable = {
|
|
124
|
-
|
|
132
|
+
stable = {
|
|
133
|
+
"id": document["id"], "kind": document["kind"], "title": document["title"],
|
|
134
|
+
"slug": document["slug"], "locale": document["locale"], "status": status,
|
|
135
|
+
"excerpt": document.get("excerpt", ""), "content": document["content"],
|
|
136
|
+
"canonicalUrl": document.get("canonicalUrl"), "publishAt": document.get("publishAt"),
|
|
137
|
+
}
|
|
138
|
+
# Provenance is evidence about the source, not the content identity. A
|
|
139
|
+
# provider checksum must never turn a no-op save into a new revision.
|
|
140
|
+
document_checksum = checksum(stable)
|
|
125
141
|
existing = self.connection.execute("SELECT * FROM maggiedash_documents WHERE id=?", (document["id"],)).fetchone()
|
|
126
142
|
if existing and existing["status"] != status:
|
|
127
143
|
raise ValueError("status changes must use transition()")
|
|
@@ -183,10 +199,10 @@ class MaggieDashStore:
|
|
|
183
199
|
row = self.connection.execute("SELECT status, trashed_at FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
|
|
184
200
|
if not row:
|
|
185
201
|
raise ValueError("document not found")
|
|
186
|
-
if row["trashed_at"]:
|
|
202
|
+
if row["trashed_at"] or row["status"] == "trashed":
|
|
187
203
|
return self.get_document(project_id, document_id) or {}
|
|
188
204
|
timestamp = now()
|
|
189
|
-
self.connection.execute("UPDATE maggiedash_documents SET trashed_at=?, trashed_from_status=?, updated_at=? WHERE id=?", (timestamp, row["status"], timestamp, document_id))
|
|
205
|
+
self.connection.execute("UPDATE maggiedash_documents SET status='trashed', trashed_at=?, trashed_from_status=?, updated_at=? WHERE id=?", (timestamp, row["status"], timestamp, document_id))
|
|
190
206
|
self._audit(project_id, "document.trashed", "document", document_id, actor_id, reason)
|
|
191
207
|
self.connection.commit()
|
|
192
208
|
return self.get_document(project_id, document_id) or {}
|
|
@@ -195,15 +211,38 @@ class MaggieDashStore:
|
|
|
195
211
|
row = self.connection.execute("SELECT trashed_at, trashed_from_status FROM maggiedash_documents WHERE project_id=? AND id=?", (project_id, document_id)).fetchone()
|
|
196
212
|
if not row:
|
|
197
213
|
raise ValueError("document not found")
|
|
198
|
-
if not row["trashed_at"]:
|
|
214
|
+
if not row["trashed_at"] and row["trashed_from_status"] is None:
|
|
199
215
|
return self.get_document(project_id, document_id) or {}
|
|
200
216
|
status = row["trashed_from_status"] if row["trashed_from_status"] in STATUSES else "draft"
|
|
217
|
+
if status == "trashed":
|
|
218
|
+
status = "draft"
|
|
201
219
|
timestamp = now()
|
|
202
220
|
self.connection.execute("UPDATE maggiedash_documents SET status=?, trashed_at=NULL, trashed_from_status=NULL, updated_at=? WHERE id=?", (status, timestamp, document_id))
|
|
203
221
|
self._audit(project_id, "document.restored", "document", document_id, actor_id, reason)
|
|
204
222
|
self.connection.commit()
|
|
205
223
|
return self.get_document(project_id, document_id) or {}
|
|
206
224
|
|
|
225
|
+
def publish_due(self, project_id: str, now_value: str | None = None, actor_id: str = "scheduler", reason: str = "scheduled publish tick") -> list[dict[str, Any]]:
|
|
226
|
+
"""Publish scheduled documents whose timestamp has arrived.
|
|
227
|
+
|
|
228
|
+
The status predicate is intentional: a trashed document may retain an
|
|
229
|
+
old publish timestamp, but it is never eligible for promotion.
|
|
230
|
+
"""
|
|
231
|
+
current = datetime.fromisoformat((now_value or now()).replace("Z", "+00:00"))
|
|
232
|
+
rows = self.connection.execute(
|
|
233
|
+
"SELECT id, publish_at FROM maggiedash_documents WHERE project_id=? AND status='scheduled' AND publish_at IS NOT NULL ORDER BY id",
|
|
234
|
+
(project_id,),
|
|
235
|
+
).fetchall()
|
|
236
|
+
published: list[dict[str, Any]] = []
|
|
237
|
+
for row in rows:
|
|
238
|
+
try:
|
|
239
|
+
scheduled = datetime.fromisoformat(str(row["publish_at"]).replace("Z", "+00:00"))
|
|
240
|
+
except ValueError:
|
|
241
|
+
continue
|
|
242
|
+
if scheduled.tzinfo is not None and scheduled <= current:
|
|
243
|
+
published.append(self.transition(project_id, row["id"], "published", actor_id, reason))
|
|
244
|
+
return published
|
|
245
|
+
|
|
207
246
|
def schedule_publish(self, project_id: str, document_id: str, publish_at: str, actor_id: str, reason: str) -> dict[str, Any]:
|
|
208
247
|
try:
|
|
209
248
|
target = datetime.fromisoformat(publish_at.replace("Z", "+00:00"))
|
|
@@ -237,11 +276,61 @@ class MaggieDashStore:
|
|
|
237
276
|
self.connection.commit()
|
|
238
277
|
return result
|
|
239
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
|
+
|
|
240
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]:
|
|
241
|
-
if not from_path.startswith("/") or not to_path.startswith("/") or from_path == to_path:
|
|
323
|
+
if not from_path.startswith("/") or not to_path.startswith("/") or self._normalise_path(from_path) == self._normalise_path(to_path):
|
|
242
324
|
raise ValueError("redirect paths must be distinct absolute paths")
|
|
243
325
|
if status_code not in {301, 302, 307, 308}:
|
|
244
326
|
raise ValueError("status_code must be 301, 302, 307 or 308")
|
|
327
|
+
destination = self._normalise_path(to_path)
|
|
328
|
+
chain = self.connection.execute(
|
|
329
|
+
"SELECT 1 FROM maggiedash_redirects WHERE project_id=? AND ((from_path=? OR rtrim(from_path, '/')=?) OR (to_path=? OR rtrim(to_path, '/')=?)) LIMIT 1",
|
|
330
|
+
(project_id, destination, destination, from_path, self._normalise_path(from_path)),
|
|
331
|
+
).fetchone()
|
|
332
|
+
if chain:
|
|
333
|
+
raise ValueError("redirect destination must not already redirect")
|
|
245
334
|
record = (str(uuid.uuid4()), project_id, from_path, to_path, status_code, reason, actor_id, now())
|
|
246
335
|
self.connection.execute("INSERT INTO maggiedash_redirects VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(project_id,from_path) DO UPDATE SET to_path=excluded.to_path, status_code=excluded.status_code, reason=excluded.reason, actor_id=excluded.actor_id, created_at=excluded.created_at", record)
|
|
247
336
|
self._audit(project_id, "redirect.upserted", "redirect", from_path, actor_id, reason)
|
|
@@ -249,6 +338,22 @@ class MaggieDashStore:
|
|
|
249
338
|
row = self.connection.execute("SELECT * FROM maggiedash_redirects WHERE project_id=? AND from_path=?", (project_id, from_path)).fetchone()
|
|
250
339
|
return dict(row)
|
|
251
340
|
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _normalise_path(path: str) -> str:
|
|
343
|
+
path = path.split("?", 1)[0].split("#", 1)[0]
|
|
344
|
+
return path.rstrip("/") or "/"
|
|
345
|
+
|
|
346
|
+
def resolve_redirect(self, project_id: str, path: str, response_status: int) -> dict[str, Any] | None:
|
|
347
|
+
"""Consult redirects only after the route has already returned 404."""
|
|
348
|
+
if response_status != 404:
|
|
349
|
+
return None
|
|
350
|
+
normalised = self._normalise_path(path)
|
|
351
|
+
row = self.connection.execute(
|
|
352
|
+
"SELECT * FROM maggiedash_redirects WHERE project_id=? AND (from_path=? OR rtrim(from_path, '/')=?) ORDER BY CASE WHEN from_path=? THEN 0 ELSE 1 END LIMIT 1",
|
|
353
|
+
(project_id, path, normalised, path),
|
|
354
|
+
).fetchone()
|
|
355
|
+
return dict(row) if row else None
|
|
356
|
+
|
|
252
357
|
def issue_preview(self, project_id: str, document_id: str, secret: str, ttl_seconds: int = 900) -> dict[str, Any]:
|
|
253
358
|
if not secret:
|
|
254
359
|
raise ValueError("preview secret is required")
|
|
@@ -258,7 +363,15 @@ class MaggieDashStore:
|
|
|
258
363
|
payload = f"{project_id}:{document_id}:{expires}".encode()
|
|
259
364
|
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
|
260
365
|
signature = hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).hexdigest()
|
|
261
|
-
return {
|
|
366
|
+
return {
|
|
367
|
+
"token": f"{encoded}.{signature}",
|
|
368
|
+
"expiresAt": datetime.fromtimestamp(expires, timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
369
|
+
"headers": {
|
|
370
|
+
"Cache-Control": "no-store",
|
|
371
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
372
|
+
"metaRobots": "noindex, nofollow",
|
|
373
|
+
},
|
|
374
|
+
}
|
|
262
375
|
|
|
263
376
|
@staticmethod
|
|
264
377
|
def verify_preview(token: str, secret: str, project_id: str, document_id: str) -> bool:
|