@topy-ai/maggie 0.7.48 → 0.7.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +31 -4
- package/bin/maggie.js +17 -4
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +8 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +9 -2
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +54 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate provider-neutral Maggie marketing runs and their named schedule layers.
|
|
3
|
+
|
|
4
|
+
A marketing run (`maggie-marketing-run-v1`) is one project-scoped record that
|
|
5
|
+
joins content, Social Share, Ads, and measurement work. It references source
|
|
6
|
+
revisions, capability evidence, approvals, mutations, and readbacks by ID; it
|
|
7
|
+
never copies provider secrets or large payloads.
|
|
8
|
+
|
|
9
|
+
Schedule layers keep four different meanings of "when" apart:
|
|
10
|
+
|
|
11
|
+
- `infrastructure`: when a worker wakes up (cron, systemd timer, queue);
|
|
12
|
+
validated by `maggie_schedule.py`.
|
|
13
|
+
- `content_calendar`: when reviewed content or a social post may publish.
|
|
14
|
+
- `campaign_flight`: when paid campaigns may run and spend.
|
|
15
|
+
- `measurement_window`: the data collection and attribution period, including
|
|
16
|
+
conversion lag.
|
|
17
|
+
|
|
18
|
+
No function in this module calls a provider, a model, or the network.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import date, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = "maggie-marketing-run-v1"
|
|
32
|
+
SCHEDULE_LAYERS = {
|
|
33
|
+
"infrastructure": "Operations: when a worker wakes up (cron/systemd/queue); never a publish time.",
|
|
34
|
+
"content_calendar": "Editor/marketer: when a reviewed content or social item may publish.",
|
|
35
|
+
"campaign_flight": "Ads owner: when a paid campaign may run and spend.",
|
|
36
|
+
"measurement_window": "Analytics: data collection and attribution period, including conversion lag.",
|
|
37
|
+
}
|
|
38
|
+
LAYER_STATUSES = {"planned", "active", "paused", "completed", "cancelled"}
|
|
39
|
+
RUN_MODES = {"draft", "scheduled", "executing", "completed", "cancelled"}
|
|
40
|
+
APPROVAL_STATES = {"pending", "approved", "rejected", "not_required"}
|
|
41
|
+
MUTATION_STATUSES = {"planned", "pending", "succeeded", "failed", "cancelled"}
|
|
42
|
+
MUTATION_KINDS = {"external_publication", "ads_change", "content_delivery", "draft_creation", "configuration"}
|
|
43
|
+
# Workflow stages a regulated-content policy may annotate. Only external
|
|
44
|
+
# publication can be gated by the policy review; everything else continues.
|
|
45
|
+
STAGES = ("delivery", "draft", "seo_geo", "measurement", "external_publication")
|
|
46
|
+
POLICY_GATED_STAGES = {"external_publication"}
|
|
47
|
+
REVIEW_STATES = {"not_required", "pending", "approved", "rejected"}
|
|
48
|
+
SECRET_KEY = re.compile(r"(?:^|_)(?:secret|token|password|api_?key|authorization|cookie)s?$", re.I)
|
|
49
|
+
SECRET_VALUE = re.compile(r"^(?:Bearer\s|sk_(?:live|test)_|ya29\.|AIza)", re.I)
|
|
50
|
+
RUN_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _day(value: object) -> date | None:
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).date() if "T" in value else date.fromisoformat(value)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _zone(value: object) -> bool:
|
|
63
|
+
if not isinstance(value, str) or not value:
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
ZoneInfo(value)
|
|
67
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _secrets(value: object, path: str, found: list[str]) -> None:
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
for key, item in value.items():
|
|
75
|
+
if SECRET_KEY.search(str(key)) and not str(key).endswith(("_ref", "Ref")):
|
|
76
|
+
found.append(f"{path}.{key}")
|
|
77
|
+
_secrets(item, f"{path}.{key}", found)
|
|
78
|
+
elif isinstance(value, list):
|
|
79
|
+
for index, item in enumerate(value):
|
|
80
|
+
_secrets(item, f"{path}[{index}]", found)
|
|
81
|
+
elif isinstance(value, str) and SECRET_VALUE.search(value):
|
|
82
|
+
found.append(path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_layers(layers: object, *, label: str = "scheduleLayers") -> tuple[list[str], list[str]]:
|
|
86
|
+
"""Validate named schedule layers shared by maggie-schedule, Social Share, Ads, and analytics."""
|
|
87
|
+
errors: list[str] = []
|
|
88
|
+
warnings: list[str] = []
|
|
89
|
+
if not isinstance(layers, list) or not layers:
|
|
90
|
+
return [f"{label} must be a non-empty list"], warnings
|
|
91
|
+
seen: set[tuple[str, str]] = set()
|
|
92
|
+
for index, layer in enumerate(layers):
|
|
93
|
+
prefix = f"{label}[{index}]"
|
|
94
|
+
if not isinstance(layer, dict):
|
|
95
|
+
errors.append(f"{prefix} must be an object")
|
|
96
|
+
continue
|
|
97
|
+
name = layer.get("layer")
|
|
98
|
+
if name not in SCHEDULE_LAYERS:
|
|
99
|
+
errors.append(f"{prefix}.layer must be one of {', '.join(SCHEDULE_LAYERS)}")
|
|
100
|
+
continue
|
|
101
|
+
key = (name, str(layer.get("id") or ""))
|
|
102
|
+
if key in seen:
|
|
103
|
+
errors.append(f"{prefix} duplicates layer {name} id {key[1] or '(none)'}")
|
|
104
|
+
seen.add(key)
|
|
105
|
+
if not _zone(layer.get("timezone")):
|
|
106
|
+
errors.append(f"{prefix}.timezone must be an IANA time zone")
|
|
107
|
+
for field in ("owner", "reconciliation"):
|
|
108
|
+
if not isinstance(layer.get(field), str) or not layer[field].strip():
|
|
109
|
+
errors.append(f"{prefix}.{field} is required")
|
|
110
|
+
if layer.get("status") not in LAYER_STATUSES:
|
|
111
|
+
errors.append(f"{prefix}.status must be one of {', '.join(sorted(LAYER_STATUSES))}")
|
|
112
|
+
if name == "infrastructure":
|
|
113
|
+
if not layer.get("scheduleRef"):
|
|
114
|
+
errors.append(f"{prefix}.scheduleRef must point at the maggie-schedule job (infrastructure has no publish time)")
|
|
115
|
+
if "start" in layer or "end" in layer:
|
|
116
|
+
warnings.append(f"{prefix} infrastructure layers describe wake-up intervals; start/end are ignored")
|
|
117
|
+
continue
|
|
118
|
+
start, end = _day(layer.get("start")), _day(layer.get("end"))
|
|
119
|
+
if not start:
|
|
120
|
+
errors.append(f"{prefix}.start must be an ISO date or date-time")
|
|
121
|
+
if name != "content_calendar" and not end:
|
|
122
|
+
errors.append(f"{prefix}.end is required for {name}")
|
|
123
|
+
if start and end and end < start:
|
|
124
|
+
errors.append(f"{prefix}.end is before start")
|
|
125
|
+
if name == "measurement_window" and not isinstance(layer.get("lagDays"), int):
|
|
126
|
+
warnings.append(f"{prefix}.lagDays is not set; attribution lag will be ignored")
|
|
127
|
+
return errors, warnings
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_policy(policy: object, stages: object, mutations: list) -> tuple[list[str], list[str]]:
|
|
131
|
+
"""Regulated-content review gates only external publication (default off, backward compatible)."""
|
|
132
|
+
errors: list[str] = []
|
|
133
|
+
warnings: list[str] = []
|
|
134
|
+
if policy is None:
|
|
135
|
+
policy = {}
|
|
136
|
+
if not isinstance(policy, dict):
|
|
137
|
+
return ["policy must be an object"], warnings
|
|
138
|
+
required = bool(policy.get("regulatedContent") and policy.get("reviewRequired", True))
|
|
139
|
+
review = policy.get("reviewState", "pending" if required else "not_required")
|
|
140
|
+
if review not in REVIEW_STATES:
|
|
141
|
+
errors.append(f"policy.reviewState must be one of {', '.join(sorted(REVIEW_STATES))}")
|
|
142
|
+
if isinstance(stages, dict):
|
|
143
|
+
for stage, state in stages.items():
|
|
144
|
+
if stage not in STAGES:
|
|
145
|
+
errors.append(f"stages.{stage} is not a known stage ({', '.join(STAGES)})")
|
|
146
|
+
continue
|
|
147
|
+
blocked = isinstance(state, dict) and state.get("status") == "blocked" and state.get("reason") == "policy_review"
|
|
148
|
+
if blocked and stage not in POLICY_GATED_STAGES:
|
|
149
|
+
errors.append(f"stages.{stage} is blocked by policy review; regulated-content review may only gate "
|
|
150
|
+
"external publication — delivery, drafts, SEO/GEO and measurement must continue")
|
|
151
|
+
elif stages is not None:
|
|
152
|
+
errors.append("stages must be an object keyed by stage")
|
|
153
|
+
if required and review != "approved":
|
|
154
|
+
for mutation in mutations:
|
|
155
|
+
if isinstance(mutation, dict) and mutation.get("kind") == "external_publication" and mutation.get("status") in {"pending", "succeeded"}:
|
|
156
|
+
errors.append(f"mutation {mutation.get('mutationId')} publishes regulated content before policy review is approved")
|
|
157
|
+
if review == "pending":
|
|
158
|
+
warnings.append("regulated-content review is pending; external publication stays held, other stages continue")
|
|
159
|
+
return errors, warnings
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_run(run: object) -> dict:
|
|
163
|
+
errors: list[str] = []
|
|
164
|
+
warnings: list[str] = []
|
|
165
|
+
if not isinstance(run, dict):
|
|
166
|
+
return {"valid": False, "errors": ["marketing run must be a JSON object"], "warnings": []}
|
|
167
|
+
if run.get("schemaVersion") != SCHEMA_VERSION:
|
|
168
|
+
errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
|
|
169
|
+
if not isinstance(run.get("runId"), str) or not RUN_ID.match(run["runId"]):
|
|
170
|
+
errors.append("runId must be a lowercase slug (3-128 chars)")
|
|
171
|
+
for field in ("projectRef", "objective"):
|
|
172
|
+
if not isinstance(run.get(field), str) or not run[field].strip():
|
|
173
|
+
errors.append(f"{field} is required")
|
|
174
|
+
conversion = run.get("conversion")
|
|
175
|
+
if not isinstance(conversion, dict) or not conversion.get("event") or not conversion.get("source"):
|
|
176
|
+
errors.append("conversion needs event and source")
|
|
177
|
+
channels = run.get("channels")
|
|
178
|
+
if not isinstance(channels, list) or not channels or not all(isinstance(item, str) and item for item in channels):
|
|
179
|
+
errors.append("channels must be a non-empty list of channel names")
|
|
180
|
+
if not _zone(run.get("timezone")):
|
|
181
|
+
errors.append("timezone must be an IANA time zone")
|
|
182
|
+
if run.get("mode") not in RUN_MODES:
|
|
183
|
+
errors.append(f"mode must be one of {', '.join(sorted(RUN_MODES))}")
|
|
184
|
+
if "scheduled_at" in run or "scheduledAt" in run:
|
|
185
|
+
errors.append("a run must not carry a single scheduled_at; use named scheduleLayers")
|
|
186
|
+
for index, revision in enumerate(run.get("sourceRevisions") or []):
|
|
187
|
+
if not isinstance(revision, dict) or not revision.get("sourceId") or not revision.get("revision"):
|
|
188
|
+
errors.append(f"sourceRevisions[{index}] needs sourceId and revision")
|
|
189
|
+
for index, ref in enumerate(run.get("capabilityEvidence") or []):
|
|
190
|
+
if not isinstance(ref, str) or not ref:
|
|
191
|
+
errors.append(f"capabilityEvidence[{index}] must be an evidence ID or path reference")
|
|
192
|
+
approval = run.get("approval")
|
|
193
|
+
if not isinstance(approval, dict) or approval.get("state") not in APPROVAL_STATES:
|
|
194
|
+
errors.append(f"approval.state must be one of {', '.join(sorted(APPROVAL_STATES))}")
|
|
195
|
+
approval = {}
|
|
196
|
+
if approval.get("state") == "approved" and not (approval.get("actor") and approval.get("approvalId")):
|
|
197
|
+
errors.append("an approved run needs approval.actor and approval.approvalId")
|
|
198
|
+
layer_errors, layer_warnings = validate_layers(run.get("scheduleLayers"))
|
|
199
|
+
errors += layer_errors
|
|
200
|
+
warnings += layer_warnings
|
|
201
|
+
mutations = run.get("mutations") or []
|
|
202
|
+
if not isinstance(mutations, list):
|
|
203
|
+
errors.append("mutations must be a list")
|
|
204
|
+
mutations = []
|
|
205
|
+
mutation_ids: set[str] = set()
|
|
206
|
+
for index, mutation in enumerate(mutations):
|
|
207
|
+
prefix = f"mutations[{index}]"
|
|
208
|
+
if not isinstance(mutation, dict) or not mutation.get("mutationId"):
|
|
209
|
+
errors.append(f"{prefix} needs mutationId")
|
|
210
|
+
continue
|
|
211
|
+
mutation_ids.add(mutation["mutationId"])
|
|
212
|
+
if mutation.get("kind") not in MUTATION_KINDS:
|
|
213
|
+
errors.append(f"{prefix}.kind must be one of {', '.join(sorted(MUTATION_KINDS))}")
|
|
214
|
+
if mutation.get("status") not in MUTATION_STATUSES:
|
|
215
|
+
errors.append(f"{prefix}.status must be one of {', '.join(sorted(MUTATION_STATUSES))}")
|
|
216
|
+
if mutation.get("kind") in {"external_publication", "ads_change"}:
|
|
217
|
+
if not mutation.get("idempotencyKey"):
|
|
218
|
+
errors.append(f"{prefix} external mutation needs an idempotencyKey")
|
|
219
|
+
if mutation.get("status") in {"pending", "succeeded"} and approval.get("state") != "approved":
|
|
220
|
+
errors.append(f"{prefix} executed before the run was approved")
|
|
221
|
+
readback_refs = set()
|
|
222
|
+
for index, readback in enumerate(run.get("readbacks") or []):
|
|
223
|
+
if not isinstance(readback, dict) or not readback.get("readbackId"):
|
|
224
|
+
errors.append(f"readbacks[{index}] needs readbackId")
|
|
225
|
+
continue
|
|
226
|
+
ref = readback.get("mutationRef")
|
|
227
|
+
if ref and ref not in mutation_ids:
|
|
228
|
+
errors.append(f"readbacks[{index}].mutationRef {ref} does not match a mutation")
|
|
229
|
+
readback_refs.add(ref)
|
|
230
|
+
for mutation in mutations:
|
|
231
|
+
if isinstance(mutation, dict) and mutation.get("status") == "succeeded" and mutation.get("mutationId") not in readback_refs:
|
|
232
|
+
message = f"mutation {mutation.get('mutationId')} succeeded without a readback reference"
|
|
233
|
+
(errors if run.get("mode") == "completed" else warnings).append(message)
|
|
234
|
+
policy_errors, policy_warnings = validate_policy(run.get("policy"), run.get("stages"), mutations)
|
|
235
|
+
errors += policy_errors
|
|
236
|
+
warnings += policy_warnings
|
|
237
|
+
leaked: list[str] = []
|
|
238
|
+
_secrets(run, "run", leaked)
|
|
239
|
+
if leaked:
|
|
240
|
+
errors.append("run must reference secrets, not contain them: " + ", ".join(leaked))
|
|
241
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings,
|
|
242
|
+
"runId": run.get("runId"), "layers": sorted({layer.get("layer") for layer in run.get("scheduleLayers") or [] if isinstance(layer, dict)} - {None})}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main() -> int:
|
|
246
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
247
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
248
|
+
check = sub.add_parser("validate", help="validate a maggie-marketing-run-v1 document")
|
|
249
|
+
check.add_argument("input")
|
|
250
|
+
layers = sub.add_parser("layers", help="validate a scheduleLayers list, or print the layer model with no input")
|
|
251
|
+
layers.add_argument("input", nargs="?")
|
|
252
|
+
args = parser.parse_args()
|
|
253
|
+
try:
|
|
254
|
+
if args.command == "layers" and not args.input:
|
|
255
|
+
print(json.dumps({"layers": SCHEDULE_LAYERS}, indent=2))
|
|
256
|
+
return 0
|
|
257
|
+
value = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
258
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
259
|
+
parser.error(str(error))
|
|
260
|
+
return 2
|
|
261
|
+
if args.command == "validate":
|
|
262
|
+
result = validate_run(value)
|
|
263
|
+
else:
|
|
264
|
+
items = value.get("scheduleLayers") if isinstance(value, dict) else value
|
|
265
|
+
errors, warnings = validate_layers(items)
|
|
266
|
+
result = {"valid": not errors, "errors": errors, "warnings": warnings}
|
|
267
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
268
|
+
return 0 if result["valid"] else 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
raise SystemExit(main())
|