@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.
Files changed (31) hide show
  1. package/README.md +29 -9
  2. package/README.zh-TW.md +5 -1
  3. package/bin/maggie.js +18 -2
  4. package/bundled-contracts/maggie-deployment/release-runner-v1.json +14 -0
  5. package/bundled-contracts/maggiedash/README.md +22 -0
  6. package/bundled-contracts/maggiedash/agent-content-v1.json +15 -0
  7. package/bundled-contracts/maggiedash/redirect-policy-v1.json +15 -0
  8. package/bundled-contracts/maggiedash/section-identity-v1.json +14 -0
  9. package/bundled-contracts/maggiedash/telemetry-policy-v1.json +18 -0
  10. package/bundled-contracts/maggiedash/translation-cache-policy-v1.json +15 -0
  11. package/bundled-references/maggiedash-telemetry-contract.md +12 -0
  12. package/bundled-skills/maggie-dash/SKILL.md +76 -3
  13. package/bundled-skills/maggie-deployment/SKILL.md +20 -2
  14. package/bundled-skills/maggie-ops/SKILL.md +23 -2
  15. package/bundled-skills/maggie-seo-geo/SKILL.md +16 -0
  16. package/bundled-templates/maggiedash/dashboard-ui-contract.json +8 -7
  17. package/bundled-templates/maggiedash/section-registry.json +12 -0
  18. package/bundled-tools/clis/maggie_agent_content.py +75 -0
  19. package/bundled-tools/clis/maggie_dash.py +112 -1
  20. package/bundled-tools/clis/maggie_deployment.py +51 -0
  21. package/bundled-tools/clis/maggie_ops.py +23 -0
  22. package/bundled-tools/clis/maggie_verification.py +33 -0
  23. package/bundled-tools/runtime/agent_runtime.py +38 -0
  24. package/bundled-tools/runtime/analytics_traffic.py +6 -1
  25. package/bundled-tools/runtime/maggie_dash_store.py +121 -8
  26. package/bundled-tools/runtime/maggie_dash_ui.py +35 -1
  27. package/bundled-tools/runtime/maggie_sections.py +143 -0
  28. package/bundled-tools/runtime/restart_gate.py +22 -0
  29. package/bundled-tools/runtime/surface_coverage.py +48 -0
  30. package/package.json +1 -1
  31. 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
- require_confirm(args)
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 is_tool_traffic(event):
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
- STATUSES = ("draft", "review", "approved", "scheduled", "published", "archived")
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 = {key: value for key, value in document.items() if key not in {"provenance", "checksum"}}
124
- document_checksum = document.get("provenance", {}).get("checksum") or checksum(stable)
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 {"token": f"{encoded}.{signature}", "expiresAt": datetime.fromtimestamp(expires, timezone.utc).isoformat().replace("+00:00", "Z")}
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: