@topy-ai/maggie 0.7.26 → 0.7.27
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 +11 -4
- package/bundled-contracts/maggiedash/README.md +5 -0
- package/bundled-references/blog-translation-ingestion.md +7 -0
- package/bundled-references/maggiedash-integration.md +19 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +21 -0
- package/bundled-skills/maggie-dash/SKILL.md +22 -0
- package/bundled-skills/maggie-deployment/SKILL.md +14 -3
- package/bundled-skills/maggie-deployment/references/vps.md +12 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +4 -1
- package/bundled-tools/clis/maggie.py +29 -1
- package/bundled-tools/clis/maggie_dash.py +108 -0
- package/bundled-tools/clis/maggie_deployment.py +43 -4
- package/bundled-tools/clis/maggie_marketplace.py +12 -4
- package/bundled-tools/integrations/maggie-api-pull.md +8 -4
- package/bundled-tools/runtime/maggie_dash_store.py +20 -0
- package/package.json +1 -1
- package/references/blog-translation-ingestion.md +7 -0
- package/references/maggiedash-integration.md +19 -0
package/README.md
CHANGED
|
@@ -60,8 +60,12 @@ Maggie keeps the existing project foundation and asks for decisions before
|
|
|
60
60
|
shared routes, analytics, or publishing boundaries change. The current
|
|
61
61
|
package ships 19 installable skills and a local-first MaggieDash foundation.
|
|
62
62
|
|
|
63
|
-
The current release is `0.7.
|
|
64
|
-
|
|
63
|
+
The current release is `0.7.27`. It adds a provider-neutral MaggieDash host
|
|
64
|
+
adapter contract, idempotent PostgreSQL starter schema, type-only backend
|
|
65
|
+
boundary, runtime endpoint conformance evidence, safe quota/error summaries,
|
|
66
|
+
stale marketplace readiness revalidation, explicit VPS port/origin planning,
|
|
67
|
+
first-release rollback evidence, and clean non-interactive bootstrap errors.
|
|
68
|
+
It retains the host-owned mobile app surface
|
|
65
69
|
contract (`maggie design app-init`/`app-validate`), direct Astro route
|
|
66
70
|
resolution, correlated feedback batches, and runtime package-version capture.
|
|
67
71
|
|
|
@@ -104,6 +108,9 @@ maggie dash api-contract --spec .maggie/openapi.json
|
|
|
104
108
|
maggie dash api-contract --spec https://example.test/openapi.json
|
|
105
109
|
maggie dash schema-audit --inventory .maggie/schema-inventory.json --fail-on-unread
|
|
106
110
|
maggie dash ui runtime-validate --evidence .maggie/dashboard-runtime.json
|
|
111
|
+
maggie dash conformance --contract .maggie/host-adapter-v2.json \
|
|
112
|
+
--evidence .maggie/maggiedash-conformance.json \
|
|
113
|
+
--output docs/maggiedash-conformance.json
|
|
107
114
|
maggie docs audit --project . --docs-dir docs --output docs/documentation-audit.json
|
|
108
115
|
```
|
|
109
116
|
|
|
@@ -398,8 +405,8 @@ artifact schemas.
|
|
|
398
405
|
Recommended upgrade sequence for the current release:
|
|
399
406
|
|
|
400
407
|
```bash
|
|
401
|
-
npx @topy-ai/maggie@0.7.
|
|
402
|
-
npx @topy-ai/maggie@0.7.
|
|
408
|
+
npx @topy-ai/maggie@0.7.27 update --project . --force
|
|
409
|
+
npx @topy-ai/maggie@0.7.27 cleanup --project .
|
|
403
410
|
```
|
|
404
411
|
|
|
405
412
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -50,6 +50,11 @@ import.
|
|
|
50
50
|
items with stable ordering, nesting, and safe destinations;
|
|
51
51
|
- [`dashboard-runtime-v1.json`](dashboard-runtime-v1.json) defines evidence
|
|
52
52
|
that an admin screen mounted, ran its checks, and completed its data calls.
|
|
53
|
+
- MaggieDash 0.2.6 also ships a screen-level host adapter contract and
|
|
54
|
+
provider-neutral install starters in the first-party distribution. The
|
|
55
|
+
`host-adapter-v2` contract lists endpoint methods, public routes, response
|
|
56
|
+
content types, and required fields; `maggie dash conformance` validates
|
|
57
|
+
sanitized runtime observations against it.
|
|
53
58
|
|
|
54
59
|
These are adapter-facing interfaces, not database schemas. A host may use
|
|
55
60
|
PostgreSQL, SQLite, or another first-party store. Activity recording happens
|
|
@@ -25,6 +25,13 @@ return partial/nonzero and store only an error category. Use one worker per
|
|
|
25
25
|
project. Host database/API schedulers must explicitly integrate this boundary;
|
|
26
26
|
installing the skill does not patch an existing host ingestion implementation.
|
|
27
27
|
|
|
28
|
+
When a host imports JavaScript helpers from strict TypeScript, ship an adjacent
|
|
29
|
+
`.d.mts` declaration for every `.mjs` module that is part of the public
|
|
30
|
+
translation/ingest boundary. Keep the declaration's nullable/error outcome
|
|
31
|
+
types aligned with the runtime and include it in the typecheck fixture. This
|
|
32
|
+
prevents a successful runtime helper from becoming an implicit `any` or a
|
|
33
|
+
nullability mismatch in the scheduled path.
|
|
34
|
+
|
|
28
35
|
## Durable work
|
|
29
36
|
|
|
30
37
|
After source persistence, enqueue translation work using an idempotency key
|
|
@@ -5,6 +5,14 @@ approvals, jobs, audit, API, and MCP boundaries. External systems are adapters;
|
|
|
5
5
|
they never become the local source of truth without an explicit migration and
|
|
6
6
|
rollback decision.
|
|
7
7
|
|
|
8
|
+
The first-party distribution installs the React admin under `./_maggie/admin`
|
|
9
|
+
and portable host starters under `maggiedash/`: an idempotent PostgreSQL
|
|
10
|
+
schema, type-only adapter boundary, and the screen-level
|
|
11
|
+
`maggiedash-host-adapter.v2` contract. The schema is a starting migration for
|
|
12
|
+
the host's normal migration system; it must not drop legacy tables or receive
|
|
13
|
+
unbound project IDs. The type file is intentionally not a fake database
|
|
14
|
+
implementation.
|
|
15
|
+
|
|
8
16
|
## Foundation
|
|
9
17
|
|
|
10
18
|
Initialize and migrate the local project contract before content or provider
|
|
@@ -35,6 +43,17 @@ in environment variables and are never copied into reports.
|
|
|
35
43
|
explicitly declares that mirror required.
|
|
36
44
|
- API Pull, booking, analytics, email, and deployment providers expose health,
|
|
37
45
|
normalized errors, and explicit configuration schemas.
|
|
46
|
+
- Dashboard hosts must expose the exact method, route, response content type,
|
|
47
|
+
and required fields declared by `host-adapter-v2`. Create sanitized runtime
|
|
48
|
+
observations and validate them with `maggie dash conformance`; a static
|
|
49
|
+
component scan is not evidence that a screen works. Astro hosts that exclude
|
|
50
|
+
underscore filesystem segments must rewrite `/_maggie/section-preview` once
|
|
51
|
+
to the internal preview route without disabling origin checks.
|
|
52
|
+
|
|
53
|
+
For API Pull, retain only bounded provider messages and effective quota values
|
|
54
|
+
in lifecycle state. A quota-exhausted response means daily delivery remaining
|
|
55
|
+
is zero even if an older allowance field says otherwise; raw provider bodies
|
|
56
|
+
never enter `.maggie` reports.
|
|
38
57
|
|
|
39
58
|
## Ordered workflow
|
|
40
59
|
|
|
@@ -22,6 +22,11 @@ project-specific design, service, SEO, operations, and deployment adapters.
|
|
|
22
22
|
3. For a new MaggieDash project, run:
|
|
23
23
|
`python3 tools/clis/maggie_dash.py init --project PROJECT --confirm` and
|
|
24
24
|
`python3 tools/clis/maggie_dash.py migrate --project PROJECT --confirm`.
|
|
25
|
+
If the first-party dashboard is installed, also run
|
|
26
|
+
`maggie dash install --project PROJECT --confirm`; this copies the
|
|
27
|
+
provider-neutral backend type boundary and idempotent starter schema beside
|
|
28
|
+
the admin UI. Apply the schema through the host migration system, never by
|
|
29
|
+
dropping legacy tables.
|
|
25
30
|
4. Advance the ordered foundation gate:
|
|
26
31
|
|
|
27
32
|
```bash
|
|
@@ -46,6 +51,19 @@ project-specific design, service, SEO, operations, and deployment adapters.
|
|
|
46
51
|
9. Run build, route, SEO, accessibility, content, and deployment checks; hand
|
|
47
52
|
private content operations to `maggie-ops`.
|
|
48
53
|
|
|
54
|
+
For an installed dashboard, create sanitized runtime observations for every
|
|
55
|
+
endpoint in `maggiedash/contracts/host-adapter-v2.json` and run:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
maggie dash conformance --project PROJECT \
|
|
59
|
+
--contract maggiedash/contracts/host-adapter-v2.json \
|
|
60
|
+
--evidence .maggie/maggiedash-conformance.json \
|
|
61
|
+
--output docs/maggiedash-conformance.json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The report checks method, public route, 2xx status, content type, and required
|
|
65
|
+
response fields. It does not accept a static source scan as runtime evidence.
|
|
66
|
+
|
|
49
67
|
## Decisions and safety
|
|
50
68
|
|
|
51
69
|
- Preserve detected framework, language, router, UI system, icon set, fonts,
|
|
@@ -58,6 +76,9 @@ project-specific design, service, SEO, operations, and deployment adapters.
|
|
|
58
76
|
stop for manual conflict review; malformed content is never published.
|
|
59
77
|
- Do not build a second CMS or editor beside MaggieDash. Use the shared
|
|
60
78
|
ContentDocument contract and project-scoped roles.
|
|
79
|
+
- If stdin is not interactive, bootstrap must receive
|
|
80
|
+
`--non-interactive --answers-file FILE --confirm`; it exits with a concise
|
|
81
|
+
remediation message instead of emitting an EOF traceback.
|
|
61
82
|
|
|
62
83
|
## Completion gate
|
|
63
84
|
|
|
@@ -46,6 +46,28 @@ owns the framework route, email/password session middleware, database, media
|
|
|
46
46
|
storage, provider credentials, and `/api/maggie/*` adapter endpoints. Read the
|
|
47
47
|
MaggieDash host adapter contract before adding a new framework adapter.
|
|
48
48
|
|
|
49
|
+
The install also places a provider-neutral backend type boundary, an
|
|
50
|
+
idempotent PostgreSQL starter schema, and the screen-level host adapter
|
|
51
|
+
contract under `maggiedash/`. These are reviewable starting points, not a
|
|
52
|
+
portable database implementation. Apply the schema through the host's normal
|
|
53
|
+
migration system, keep project IDs bound as query parameters, and implement
|
|
54
|
+
the declared endpoint fields before enabling a dashboard screen. The
|
|
55
|
+
underscore-prefixed public preview route must be rewritten once by the host
|
|
56
|
+
framework when that framework excludes underscore filesystem segments.
|
|
57
|
+
|
|
58
|
+
Validate the live adapter with sanitized browser/runtime observations:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
maggie dash conformance --project . \
|
|
62
|
+
--contract maggiedash/contracts/host-adapter-v2.json \
|
|
63
|
+
--evidence .maggie/maggiedash-conformance.json \
|
|
64
|
+
--output docs/maggiedash-conformance.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This is a runtime contract gate, not a source-only check; it fails when a
|
|
68
|
+
declared endpoint is missing, uses the wrong route/method, returns a non-2xx
|
|
69
|
+
status or omits required fields.
|
|
70
|
+
|
|
49
71
|
MaggieDash 0.2.4 adds two reusable operator workspaces. Activity is a read-only
|
|
50
72
|
view backed by `/api/maggie/activity.json`; it supports actor/action filters,
|
|
51
73
|
refresh, and a safe unavailable state. Navigation is an ordered,
|
|
@@ -123,10 +123,18 @@ Generate a reviewable VPS plan and configuration artifacts locally:
|
|
|
123
123
|
python3 tools/clis/maggie_deployment.py /path/to/project \
|
|
124
124
|
--vps-plan --domain example.co.uk --service example \
|
|
125
125
|
--release-root /var/www/example \
|
|
126
|
+
--node-port 4321 \
|
|
126
127
|
--plan-dir .maggie/deployment/vps-staging \
|
|
127
128
|
--runner-output .maggie/deployment/vps-staging/release-runner.sh
|
|
128
129
|
```
|
|
129
130
|
|
|
131
|
+
`--node-port` is intentionally explicit and must be an unused host port; the
|
|
132
|
+
planner never guesses a default because a guessed port can collide with an
|
|
133
|
+
existing service. The generated plan also records the TLS-proxy boundary:
|
|
134
|
+
keep Astro `security.checkOrigin` enabled, configure the canonical HTTPS
|
|
135
|
+
`allowedDomains`, and forward the public host/protocol only from a trusted
|
|
136
|
+
reverse proxy.
|
|
137
|
+
|
|
130
138
|
The output contains only the immutable release/current layout, systemd unit,
|
|
131
139
|
Nginx reverse-proxy config, health commands, rollback command, and an ordered
|
|
132
140
|
reviewable release runner. The runner keeps migrations before the symlink flip,
|
|
@@ -237,9 +245,12 @@ Review the generated runner before execution. Its order is install → typecheck
|
|
|
237
245
|
retention keeps the current release and one rollback candidate.
|
|
238
246
|
|
|
239
247
|
Production additionally requires `docs/deployment-rollback-smoke.json` with
|
|
240
|
-
`passed: true`, `environment: production`,
|
|
241
|
-
|
|
242
|
-
|
|
248
|
+
`passed: true`, `environment: production`, and `testedAt`. For an upgrade it
|
|
249
|
+
must include a non-empty `previousRelease`. For the first production release,
|
|
250
|
+
set `releaseKind: "first"` and record a non-empty `notApplicableReason`; the
|
|
251
|
+
runner also requires `RELEASE_KIND=first`. A rollback command in a plan is not
|
|
252
|
+
proof that the rollback was tested. Staging does not require this
|
|
253
|
+
production-only evidence.
|
|
243
254
|
|
|
244
255
|
## Required workflow
|
|
245
256
|
|
|
@@ -76,9 +76,16 @@ Create a local, secret-free plan before remote execution:
|
|
|
76
76
|
python3 tools/clis/maggie_deployment.py . --vps-plan \
|
|
77
77
|
--domain example.co.uk --service example \
|
|
78
78
|
--release-root /var/www/example \
|
|
79
|
+
--node-port 4321 \
|
|
79
80
|
--plan-dir .maggie/deployment/vps-staging
|
|
80
81
|
```
|
|
81
82
|
|
|
83
|
+
Always choose and pass an unused `--node-port`; no default is assumed because
|
|
84
|
+
the planner cannot safely know which ports are occupied on the target host.
|
|
85
|
+
The generated Nginx/Astro boundary keeps `checkOrigin` enabled and requires
|
|
86
|
+
canonical HTTPS `allowedDomains` plus trusted `X-Forwarded-Host`/
|
|
87
|
+
`X-Forwarded-Proto` handling when TLS terminates at the proxy.
|
|
88
|
+
|
|
82
89
|
Review the generated systemd and Nginx files, then obtain explicit approval
|
|
83
90
|
before installing them on a host.
|
|
84
91
|
|
|
@@ -106,5 +113,8 @@ canonical URLs, booking links, and protected Ops/API routes. Check both apex
|
|
|
106
113
|
and `www` when configured. Record HTTP status, commit, and release.
|
|
107
114
|
|
|
108
115
|
Rollback switches `current` to the previous immutable release and restarts the
|
|
109
|
-
service. If migrations are not backward-compatible, stop before deployment
|
|
110
|
-
|
|
116
|
+
service. If migrations are not backward-compatible, stop before deployment and
|
|
117
|
+
provide a tested restore or database rollback plan. The first production
|
|
118
|
+
release may declare rollback as not applicable only with `releaseKind: "first"`,
|
|
119
|
+
a non-empty `notApplicableReason`, and `RELEASE_KIND=first`; upgrades still
|
|
120
|
+
need tested `previousRelease` evidence.
|
|
@@ -74,7 +74,10 @@ python3 tools/clis/maggie_marketplace.py resume <template-id>
|
|
|
74
74
|
|
|
75
75
|
If generated assets are missing, the job stops at `WAITING_FOR_ASSETS`; cloned
|
|
76
76
|
images are never accepted as generated-asset fallbacks. Only a job at `READY`
|
|
77
|
-
is complete.
|
|
77
|
+
is complete. A previously `READY` job is revalidated against the current
|
|
78
|
+
validator before it is reported complete. If the validator has changed or the
|
|
79
|
+
artifact drifted, the job becomes `STALE` and must be resumed or rebuilt; do
|
|
80
|
+
not treat an old readiness flag as proof of current validity.
|
|
78
81
|
|
|
79
82
|
Each import also creates `tasks.json`. It is the workflow ledger: it stores
|
|
80
83
|
source/output inventories for logos, icons, images, headings, links, buttons,
|
|
@@ -426,8 +426,29 @@ def command_api_lifecycle(args: argparse.Namespace) -> int:
|
|
|
426
426
|
"total_posts", "matchedPosts", "matched_posts", "eligible",
|
|
427
427
|
"unmatched", "queued", "accepted", "created", "updated",
|
|
428
428
|
"skipped", "removed", "nextCursor", "next_cursor",
|
|
429
|
+
"message", "error", "quota_exhausted", "quotaExhausted",
|
|
430
|
+
"quotaDailyRemaining", "quota_daily_remaining",
|
|
431
|
+
"quotaMonthlyRemaining", "quota_monthly_remaining",
|
|
429
432
|
)
|
|
430
|
-
summary = {
|
|
433
|
+
summary = {}
|
|
434
|
+
for key in allowed:
|
|
435
|
+
if key not in value or not isinstance(value[key], (str, int, float, bool, type(None))):
|
|
436
|
+
continue
|
|
437
|
+
item = value[key]
|
|
438
|
+
if key in {"message", "error"} and isinstance(item, str):
|
|
439
|
+
item = " ".join(item.split())[:300]
|
|
440
|
+
summary[key] = item
|
|
441
|
+
quota = value.get("quota")
|
|
442
|
+
if isinstance(quota, dict):
|
|
443
|
+
quota_summary = {}
|
|
444
|
+
for window_name, window in quota.items():
|
|
445
|
+
if not isinstance(window, dict) or not isinstance(window.get("remaining"), int):
|
|
446
|
+
continue
|
|
447
|
+
quota_summary[str(window_name)] = {"remaining": window["remaining"]}
|
|
448
|
+
if quota_summary:
|
|
449
|
+
summary["quota"] = quota_summary
|
|
450
|
+
if value.get("quota_exhausted") is True or value.get("quotaExhausted") is True:
|
|
451
|
+
summary["effective_delivery_remaining"] = 0
|
|
431
452
|
items = value.get("items")
|
|
432
453
|
if isinstance(items, list):
|
|
433
454
|
summary["item_count"] = len(items)
|
|
@@ -639,6 +660,13 @@ def interview_value(key: str, question: str, current: str, choices: list[str]) -
|
|
|
639
660
|
def command_bootstrap_interview(args: argparse.Namespace) -> int:
|
|
640
661
|
root = Path(args.project).resolve()
|
|
641
662
|
analysis = detect(root)
|
|
663
|
+
if not args.non_interactive and not sys.stdin.isatty():
|
|
664
|
+
print(
|
|
665
|
+
"NON_INTERACTIVE_REQUIRED: stdin is not interactive; use --non-interactive "
|
|
666
|
+
"--answers-file answers.json --confirm after reviewing the proposal.",
|
|
667
|
+
file=sys.stderr,
|
|
668
|
+
)
|
|
669
|
+
return 2
|
|
642
670
|
answers: dict[str, str] = {}
|
|
643
671
|
if args.answers_file:
|
|
644
672
|
try:
|
|
@@ -102,8 +102,38 @@ def manifest_file_pairs(source: Path, manifest: dict[str, object], target: Path)
|
|
|
102
102
|
return pairs
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def manifest_root_file_pairs(source: Path, manifest: dict[str, object], root: Path) -> list[tuple[Path, Path, Path]]:
|
|
106
|
+
"""Return optional host-root files without changing the admin target contract."""
|
|
107
|
+
pairs: list[tuple[Path, Path, Path]] = []
|
|
108
|
+
for item in manifest.get("rootFiles", []):
|
|
109
|
+
if isinstance(item, str):
|
|
110
|
+
source_relative = Path(item)
|
|
111
|
+
target_relative = source_relative
|
|
112
|
+
elif isinstance(item, dict):
|
|
113
|
+
source_relative = Path(str(item.get("source", "")))
|
|
114
|
+
target_relative = Path(str(item.get("target", "")))
|
|
115
|
+
else:
|
|
116
|
+
raise RuntimeError("MaggieDash rootFiles entries must be strings or objects")
|
|
117
|
+
if not source_relative.parts or not target_relative.parts:
|
|
118
|
+
raise RuntimeError("MaggieDash rootFiles entries require source and target")
|
|
119
|
+
if source_relative.is_absolute() or target_relative.is_absolute() or ".." in source_relative.parts or ".." in target_relative.parts:
|
|
120
|
+
raise RuntimeError("unsafe MaggieDash rootFiles path")
|
|
121
|
+
source_item = source / source_relative
|
|
122
|
+
if source_item.is_file():
|
|
123
|
+
pairs.append((source_item, root / target_relative, source_relative))
|
|
124
|
+
continue
|
|
125
|
+
if not source_item.is_dir():
|
|
126
|
+
raise RuntimeError(f"MaggieDash rootFiles source is missing: {source_relative}")
|
|
127
|
+
for path in sorted(source_item.rglob("*")):
|
|
128
|
+
if path.is_file():
|
|
129
|
+
child = path.relative_to(source_item)
|
|
130
|
+
pairs.append((path, root / target_relative / child, source_relative / child))
|
|
131
|
+
return pairs
|
|
132
|
+
|
|
133
|
+
|
|
105
134
|
def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool) -> dict[str, object]:
|
|
106
135
|
pairs = manifest_file_pairs(source, manifest, target)
|
|
136
|
+
root_pairs = manifest_root_file_pairs(source, manifest, target.parents[1])
|
|
107
137
|
files: list[dict[str, str]] = []
|
|
108
138
|
source_relative = {relative for _, _, relative in pairs}
|
|
109
139
|
for source_file, destination, relative in pairs:
|
|
@@ -119,6 +149,15 @@ def command_install_diff(source: Path, manifest: dict[str, object], target: Path
|
|
|
119
149
|
else:
|
|
120
150
|
action = "update" if force else "preserve"
|
|
121
151
|
files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
|
|
152
|
+
for source_file, destination, relative in root_pairs:
|
|
153
|
+
compare_file = destination
|
|
154
|
+
if not compare_file.exists():
|
|
155
|
+
action = "add"
|
|
156
|
+
elif compare_file.read_bytes() == source_file.read_bytes():
|
|
157
|
+
action = "unchanged"
|
|
158
|
+
else:
|
|
159
|
+
action = "update" if force else "preserve"
|
|
160
|
+
files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
|
|
122
161
|
if compare_dir and compare_dir.exists():
|
|
123
162
|
for existing in sorted(compare_dir.rglob("*")):
|
|
124
163
|
if not existing.is_file():
|
|
@@ -204,6 +243,13 @@ def command_install(args: argparse.Namespace) -> int:
|
|
|
204
243
|
current_installed = [str(destination)]
|
|
205
244
|
installed.extend(current_installed)
|
|
206
245
|
preserved.extend(current_preserved)
|
|
246
|
+
for source_file, destination, _ in manifest_root_file_pairs(source, manifest, root):
|
|
247
|
+
if destination.exists() and not args.force:
|
|
248
|
+
preserved.append(str(destination))
|
|
249
|
+
else:
|
|
250
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
shutil.copy2(source_file, destination)
|
|
252
|
+
installed.append(str(destination))
|
|
207
253
|
state = {
|
|
208
254
|
"schemaVersion": "maggiedash-install.v1",
|
|
209
255
|
"status": "installed",
|
|
@@ -442,6 +488,62 @@ def command_api_contract(args: argparse.Namespace) -> int:
|
|
|
442
488
|
return 0 if result["passed"] else 1
|
|
443
489
|
|
|
444
490
|
|
|
491
|
+
def command_conformance(args: argparse.Namespace) -> int:
|
|
492
|
+
"""Validate sanitized runtime evidence for every declared host endpoint."""
|
|
493
|
+
project = Path(getattr(args, "project", ".")).resolve()
|
|
494
|
+
def input_path(value: str) -> Path:
|
|
495
|
+
path = Path(value)
|
|
496
|
+
return path if path.is_absolute() else project / path
|
|
497
|
+
contract = json.loads(input_path(args.contract).read_text(encoding="utf-8"))
|
|
498
|
+
evidence = json.loads(input_path(args.evidence).read_text(encoding="utf-8"))
|
|
499
|
+
endpoints = contract.get("x-endpoints") or contract.get("endpoints") or contract.get("properties", {}).get("endpoints", {}).get("x-endpoints", [])
|
|
500
|
+
observations = evidence.get("observations") if isinstance(evidence, dict) else None
|
|
501
|
+
errors: list[str] = []
|
|
502
|
+
if not isinstance(endpoints, list) or not endpoints:
|
|
503
|
+
errors.append("contract must declare at least one endpoint")
|
|
504
|
+
endpoints = []
|
|
505
|
+
if not isinstance(observations, list):
|
|
506
|
+
errors.append("evidence.observations must be an array")
|
|
507
|
+
observations = []
|
|
508
|
+
by_id = {str(item.get("id")): item for item in observations if isinstance(item, dict) and item.get("id")}
|
|
509
|
+
checked = []
|
|
510
|
+
for endpoint in endpoints:
|
|
511
|
+
endpoint_id = str(endpoint.get("id", "")) if isinstance(endpoint, dict) else ""
|
|
512
|
+
item_errors: list[str] = []
|
|
513
|
+
observation = by_id.get(endpoint_id)
|
|
514
|
+
if not observation:
|
|
515
|
+
item_errors.append("missing runtime observation")
|
|
516
|
+
else:
|
|
517
|
+
if observation.get("method") != endpoint.get("method"):
|
|
518
|
+
item_errors.append("observed method does not match contract")
|
|
519
|
+
if observation.get("route") != endpoint.get("route"):
|
|
520
|
+
item_errors.append("observed route does not match contract")
|
|
521
|
+
status = observation.get("httpStatus")
|
|
522
|
+
if not isinstance(status, int) or isinstance(status, bool) or not 200 <= status < 300:
|
|
523
|
+
item_errors.append("endpoint did not return a 2xx status")
|
|
524
|
+
expected_type = endpoint.get("response", {}).get("contentType")
|
|
525
|
+
if observation.get("contentType") != expected_type:
|
|
526
|
+
item_errors.append("observed content type does not match contract")
|
|
527
|
+
fields = observation.get("responseFields")
|
|
528
|
+
required = endpoint.get("response", {}).get("requiredFields", [])
|
|
529
|
+
if not isinstance(fields, list) or not set(required).issubset(set(fields)):
|
|
530
|
+
item_errors.append("response is missing one or more declared fields")
|
|
531
|
+
if observation.get("passed") is not True:
|
|
532
|
+
item_errors.append("runtime observation is not passed")
|
|
533
|
+
checked.append({"id": endpoint_id, "passed": not item_errors, "errors": item_errors})
|
|
534
|
+
duplicate_ids = len(by_id) != len(observations)
|
|
535
|
+
if duplicate_ids:
|
|
536
|
+
errors.append("evidence contains duplicate or invalid observation ids")
|
|
537
|
+
report = {"schemaVersion": "maggiedash-host-conformance.v1", "checked": len(checked), "endpoints": checked, "errors": errors, "passed": not errors and bool(checked) and all(item["passed"] for item in checked)}
|
|
538
|
+
if args.output:
|
|
539
|
+
output = input_path(args.output)
|
|
540
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
542
|
+
report["output"] = str(output)
|
|
543
|
+
emit(report)
|
|
544
|
+
return 0 if report["passed"] else 1
|
|
545
|
+
|
|
546
|
+
|
|
445
547
|
def command_schema_audit(args: argparse.Namespace) -> int:
|
|
446
548
|
value = json.loads(Path(args.inventory).resolve().read_text(encoding="utf-8"))
|
|
447
549
|
result = audit_schema_inventory(value, fail_on_unread=args.fail_on_unread)
|
|
@@ -603,6 +705,12 @@ def parser() -> argparse.ArgumentParser:
|
|
|
603
705
|
api_contract = sub.add_parser("api-contract", help="validate a local or live declared request and success-response contract")
|
|
604
706
|
api_contract.add_argument("--spec", required=True, help="local JSON file or HTTPS URL (loopback HTTP is allowed for development)")
|
|
605
707
|
api_contract.set_defaults(func=command_api_contract)
|
|
708
|
+
conformance = sub.add_parser("conformance", help="validate one sanitized runtime observation for every declared MaggieDash endpoint")
|
|
709
|
+
conformance.add_argument("--project", default=".", help="host project root for relative contract, evidence, and output paths")
|
|
710
|
+
conformance.add_argument("--contract", required=True, help="MaggieDash host adapter contract JSON")
|
|
711
|
+
conformance.add_argument("--evidence", required=True, help="sanitized endpoint observations JSON")
|
|
712
|
+
conformance.add_argument("--output")
|
|
713
|
+
conformance.set_defaults(func=command_conformance)
|
|
606
714
|
schema_audit = sub.add_parser("schema-audit", help="find declared tables with missing reader evidence")
|
|
607
715
|
schema_audit.add_argument("--inventory", required=True, help="host-produced schema inventory JSON")
|
|
608
716
|
schema_audit.add_argument("--fail-on-unread", action="store_true")
|
|
@@ -55,8 +55,7 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
55
55
|
rollback_passed = False
|
|
56
56
|
if rollback_evidence.exists():
|
|
57
57
|
try:
|
|
58
|
-
|
|
59
|
-
rollback_passed = bool(evidence.get("passed") is True and evidence.get("environment") == "production" and evidence.get("previousRelease") and evidence.get("testedAt"))
|
|
58
|
+
rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8"))) ["passed"]
|
|
60
59
|
except (OSError, json.JSONDecodeError):
|
|
61
60
|
rollback_passed = False
|
|
62
61
|
migration_manifest = project / ".maggie" / "migration-manifest.json"
|
|
@@ -79,12 +78,14 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
79
78
|
return result
|
|
80
79
|
|
|
81
80
|
|
|
82
|
-
def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> dict:
|
|
81
|
+
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None) -> dict:
|
|
83
82
|
"""Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
|
|
84
83
|
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
|
|
85
84
|
raise ValueError("domain must be a hostname without a scheme or path")
|
|
86
85
|
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
|
|
87
86
|
raise ValueError("service must contain only safe systemd name characters")
|
|
87
|
+
if node_port is None:
|
|
88
|
+
raise ValueError("node port must be supplied explicitly; choose an unused port for this host")
|
|
88
89
|
if not 1024 <= node_port <= 65535:
|
|
89
90
|
raise ValueError("node port must be between 1024 and 65535")
|
|
90
91
|
root = release_root.rstrip("/")
|
|
@@ -97,6 +98,12 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
|
|
|
97
98
|
"release_root": root,
|
|
98
99
|
"current_release": current,
|
|
99
100
|
"node_port": node_port,
|
|
101
|
+
"proxy": {
|
|
102
|
+
"trust_forwarded_proto": True,
|
|
103
|
+
"forwarded_headers": ["X-Forwarded-Host", "X-Forwarded-Proto"],
|
|
104
|
+
"origin_check": "Astro security.checkOrigin remains enabled",
|
|
105
|
+
"allowed_domains_required": True,
|
|
106
|
+
},
|
|
100
107
|
"release_layout": f"{root}/releases/<immutable-release> plus current symlink",
|
|
101
108
|
"retention": {"keep": 2, "preserve": [current, f"{root}/releases/<previous-release>"], "prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf --"},
|
|
102
109
|
"staging_first": True,
|
|
@@ -141,6 +148,32 @@ def validate_data_checkpoint(path: Path) -> dict:
|
|
|
141
148
|
return {"passed": not errors, "declared": True, "errors": errors}
|
|
142
149
|
|
|
143
150
|
|
|
151
|
+
def validate_rollback_evidence(value: object) -> dict:
|
|
152
|
+
"""Validate release rollback evidence, including the explicitly safe first release."""
|
|
153
|
+
if not isinstance(value, dict):
|
|
154
|
+
return {"passed": False, "errors": ["rollback evidence must be an object"]}
|
|
155
|
+
errors = []
|
|
156
|
+
if value.get("schemaVersion") not in {None, "maggie-deployment-rollback.v1"}:
|
|
157
|
+
errors.append("rollback evidence schemaVersion is unsupported")
|
|
158
|
+
if value.get("passed") is not True:
|
|
159
|
+
errors.append("rollback evidence is not passed")
|
|
160
|
+
if value.get("environment") != "production":
|
|
161
|
+
errors.append("rollback evidence must target production")
|
|
162
|
+
if not value.get("testedAt"):
|
|
163
|
+
errors.append("rollback evidence testedAt is required")
|
|
164
|
+
release_kind = value.get("releaseKind", "upgrade")
|
|
165
|
+
if release_kind not in {"first", "upgrade"}:
|
|
166
|
+
errors.append("rollback evidence releaseKind must be first or upgrade")
|
|
167
|
+
if release_kind == "first":
|
|
168
|
+
if value.get("previousRelease"):
|
|
169
|
+
errors.append("first release evidence must not claim a previous release")
|
|
170
|
+
if not value.get("notApplicableReason"):
|
|
171
|
+
errors.append("first release evidence needs notApplicableReason")
|
|
172
|
+
elif not value.get("previousRelease"):
|
|
173
|
+
errors.append("upgrade rollback evidence requires previousRelease")
|
|
174
|
+
return {"passed": not errors, "errors": errors}
|
|
175
|
+
|
|
176
|
+
|
|
144
177
|
def validate_scheduler_request(method: str, origin: str | None, has_auth: bool) -> dict:
|
|
145
178
|
errors = []
|
|
146
179
|
if method.upper() == "POST":
|
|
@@ -210,6 +243,7 @@ WantedBy=multi-user.target
|
|
|
210
243
|
proxy_set_header Host $host;
|
|
211
244
|
proxy_set_header X-Real-IP $remote_addr;
|
|
212
245
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
246
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
213
247
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
214
248
|
}}
|
|
215
249
|
}}
|
|
@@ -230,10 +264,15 @@ set -euo pipefail
|
|
|
230
264
|
|
|
231
265
|
# Migration precedes the symlink flip; later releases carry agent state.
|
|
232
266
|
: "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
|
|
267
|
+
RELEASE_KIND="${{RELEASE_KIND:-upgrade}}"
|
|
233
268
|
RELEASE_ROOT="{root}"
|
|
234
269
|
CURRENT="{current}"
|
|
235
270
|
RELEASE="$RELEASE_ROOT/releases/$RELEASE_ID"
|
|
236
271
|
PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
|
272
|
+
if [ "$RELEASE_KIND" != "first" ] && [ -z "$PREVIOUS" ]; then
|
|
273
|
+
echo "No previous release found; set RELEASE_KIND=first for the initial production release." >&2
|
|
274
|
+
exit 2
|
|
275
|
+
fi
|
|
237
276
|
mkdir -p "$RELEASE"
|
|
238
277
|
cd "$RELEASE"
|
|
239
278
|
|
|
@@ -277,7 +316,7 @@ def main() -> int:
|
|
|
277
316
|
parser.add_argument("--domain", help="canonical hostname for --vps-plan")
|
|
278
317
|
parser.add_argument("--service", default="maggie-site", help="systemd service name")
|
|
279
318
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
280
|
-
parser.add_argument("--node-port", type=int, default=
|
|
319
|
+
parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
|
|
281
320
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
282
321
|
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
283
322
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
@@ -25,7 +25,7 @@ DEFAULT_SAMPLE_ID = "medical-spa-landing-001"
|
|
|
25
25
|
PUBLISHED_STATUSES = {"published", "ready"}
|
|
26
26
|
PHOSPHOR_BRAND_ICONS = {"ph-instagram-logo", "ph-facebook-logo", "ph-x-logo", "ph-linkedin-logo", "ph-youtube-logo", "ph-tiktok-logo"}
|
|
27
27
|
PHOSPHOR_CDN = "https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.1/src/regular/style.css"
|
|
28
|
-
JOB_PHASES = ("CREATED", "IMPORTED", "WAITING_FOR_ASSETS", "COMPLETED", "VALIDATED", "READY", "FAILED")
|
|
28
|
+
JOB_PHASES = ("CREATED", "IMPORTED", "WAITING_FOR_ASSETS", "COMPLETED", "VALIDATED", "READY", "STALE", "FAILED")
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class MetadataParser(HTMLParser):
|
|
@@ -1738,8 +1738,16 @@ def run_marketplace_job(args: argparse.Namespace) -> int:
|
|
|
1738
1738
|
if existing_path.exists() and not args.force:
|
|
1739
1739
|
existing = load_job(root, args.id)
|
|
1740
1740
|
if existing.get("phase") == "READY":
|
|
1741
|
-
|
|
1742
|
-
|
|
1741
|
+
destination = template_path(root, args.id)
|
|
1742
|
+
# READY is a claim, not a permanent exemption from newer
|
|
1743
|
+
# validators. Re-check it on every reuse and make a stale package
|
|
1744
|
+
# explicitly resumable when the contract has moved on.
|
|
1745
|
+
if validate_template(destination, args.sample.resolve() if args.sample else None) == 0:
|
|
1746
|
+
print(f"marketplace job already ready: {args.id}")
|
|
1747
|
+
return 0
|
|
1748
|
+
set_job_phase(root, existing, "STALE", staleReason="current validator rejected the recorded READY package")
|
|
1749
|
+
print(f"STALE: marketplace job {args.id} must be resumed or rebuilt")
|
|
1750
|
+
return 1
|
|
1743
1751
|
args = args_from_job(existing)
|
|
1744
1752
|
job = load_job(root, args.id) if existing_path.exists() else {
|
|
1745
1753
|
"id": args.id,
|
|
@@ -1823,7 +1831,7 @@ def resume_marketplace_job(args: argparse.Namespace) -> int:
|
|
|
1823
1831
|
print(f"marketplace job already ready: {args.job_id}")
|
|
1824
1832
|
return 0
|
|
1825
1833
|
resumed = args_from_job(job)
|
|
1826
|
-
if job.get("phase")
|
|
1834
|
+
if job.get("phase") in {"FAILED", "STALE"}:
|
|
1827
1835
|
destination = template_path(root, args.job_id)
|
|
1828
1836
|
# A post-import failure must resume from the completion checkpoint;
|
|
1829
1837
|
# importing again would erase generated assets and task evidence.
|
|
@@ -23,8 +23,11 @@ feedback payloads, or public repositories.
|
|
|
23
23
|
|
|
24
24
|
`/history` rejects invalid pagination with `422`; do not request more than 50
|
|
25
25
|
records per page. Empty `/posts` or `/updates` responses are normal and mean
|
|
26
|
-
there is currently no queued item.
|
|
27
|
-
|
|
26
|
+
there is currently no queued item. Quota responses must expose the effective
|
|
27
|
+
delivery allowance: when `quota_exhausted` is true, report the relevant daily
|
|
28
|
+
remaining value as zero and preserve a bounded provider `message`/error
|
|
29
|
+
summary. The public OpenAPI contract does not include `/whoami`; resolve
|
|
30
|
+
configuration from the API key and project context instead.
|
|
28
31
|
|
|
29
32
|
Returned content may expose provider-specific fields such as `content`,
|
|
30
33
|
`content_markdown`, `content_html`, `content_portable_text`, `body`, or `html`.
|
|
@@ -56,8 +59,9 @@ python3 tools/clis/maggie.py api lifecycle --project . \
|
|
|
56
59
|
The first command is always dry-run. The executing command persists key-scoped
|
|
57
60
|
context, remote IDs, versions, slugs, canonicals, and hashes under
|
|
58
61
|
`.maggie/ai-cmo-lifecycle.json`. Raw provider responses are not copied into
|
|
59
|
-
the checkpoint
|
|
60
|
-
|
|
62
|
+
the checkpoint; only bounded messages, effective quota fields, and safe scalar
|
|
63
|
+
outcomes are retained. Pulled content is projected as draft-only state; no
|
|
64
|
+
lifecycle step publishes local content.
|
|
61
65
|
|
|
62
66
|
To prepare and explicitly import the projection into MaggieDash drafts:
|
|
63
67
|
|
|
@@ -108,6 +108,26 @@ class MaggieDashStore:
|
|
|
108
108
|
|
|
109
109
|
def init_project(self, project_id: str, name: str, adapter: str = "maggiedash-sqlite", locale: str = "en-GB") -> dict[str, Any]:
|
|
110
110
|
timestamp = now()
|
|
111
|
+
# The default CLI identity is a provisional row. Once the operator
|
|
112
|
+
# supplies a real project id, converge that row instead of leaving an
|
|
113
|
+
# orphan project behind in the local store.
|
|
114
|
+
if project_id != "local-project":
|
|
115
|
+
has_target = self.connection.execute("SELECT 1 FROM maggiedash_projects WHERE id=?", (project_id,)).fetchone()
|
|
116
|
+
has_default = self.connection.execute("SELECT 1 FROM maggiedash_projects WHERE id=?", ("local-project",)).fetchone()
|
|
117
|
+
if not has_target and has_default:
|
|
118
|
+
# Create the destination first so SQLite's foreign-key checks
|
|
119
|
+
# remain enabled while project-scoped rows are moved.
|
|
120
|
+
self.connection.execute(
|
|
121
|
+
"INSERT INTO maggiedash_projects (id,name,adapter,locale,status,created_at,updated_at) "
|
|
122
|
+
"SELECT ?,name,adapter,locale,status,created_at,updated_at FROM maggiedash_projects WHERE id=?",
|
|
123
|
+
(project_id, "local-project"),
|
|
124
|
+
)
|
|
125
|
+
existing_tables = {row[0] for row in self.connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
126
|
+
for table in ("maggiedash_memberships", "maggiedash_documents", "maggiedash_redirects", "maggiedash_audit_events", "maggiedash_section_presets"):
|
|
127
|
+
if table not in existing_tables:
|
|
128
|
+
continue
|
|
129
|
+
self.connection.execute(f"UPDATE {table} SET project_id=? WHERE project_id=?", (project_id, "local-project"))
|
|
130
|
+
self.connection.execute("DELETE FROM maggiedash_projects WHERE id=?", ("local-project",))
|
|
111
131
|
self.connection.execute(
|
|
112
132
|
"INSERT INTO maggiedash_projects (id,name,adapter,locale,created_at,updated_at) VALUES (?,?,?,?,?,?) "
|
|
113
133
|
"ON CONFLICT(id) DO UPDATE SET name=excluded.name, adapter=excluded.adapter, locale=excluded.locale, updated_at=excluded.updated_at",
|
package/package.json
CHANGED
|
@@ -25,6 +25,13 @@ return partial/nonzero and store only an error category. Use one worker per
|
|
|
25
25
|
project. Host database/API schedulers must explicitly integrate this boundary;
|
|
26
26
|
installing the skill does not patch an existing host ingestion implementation.
|
|
27
27
|
|
|
28
|
+
When a host imports JavaScript helpers from strict TypeScript, ship an adjacent
|
|
29
|
+
`.d.mts` declaration for every `.mjs` module that is part of the public
|
|
30
|
+
translation/ingest boundary. Keep the declaration's nullable/error outcome
|
|
31
|
+
types aligned with the runtime and include it in the typecheck fixture. This
|
|
32
|
+
prevents a successful runtime helper from becoming an implicit `any` or a
|
|
33
|
+
nullability mismatch in the scheduled path.
|
|
34
|
+
|
|
28
35
|
## Durable work
|
|
29
36
|
|
|
30
37
|
After source persistence, enqueue translation work using an idempotency key
|
|
@@ -5,6 +5,14 @@ approvals, jobs, audit, API, and MCP boundaries. External systems are adapters;
|
|
|
5
5
|
they never become the local source of truth without an explicit migration and
|
|
6
6
|
rollback decision.
|
|
7
7
|
|
|
8
|
+
The first-party distribution installs the React admin under `./_maggie/admin`
|
|
9
|
+
and portable host starters under `maggiedash/`: an idempotent PostgreSQL
|
|
10
|
+
schema, type-only adapter boundary, and the screen-level
|
|
11
|
+
`maggiedash-host-adapter.v2` contract. The schema is a starting migration for
|
|
12
|
+
the host's normal migration system; it must not drop legacy tables or receive
|
|
13
|
+
unbound project IDs. The type file is intentionally not a fake database
|
|
14
|
+
implementation.
|
|
15
|
+
|
|
8
16
|
## Foundation
|
|
9
17
|
|
|
10
18
|
Initialize and migrate the local project contract before content or provider
|
|
@@ -35,6 +43,17 @@ in environment variables and are never copied into reports.
|
|
|
35
43
|
explicitly declares that mirror required.
|
|
36
44
|
- API Pull, booking, analytics, email, and deployment providers expose health,
|
|
37
45
|
normalized errors, and explicit configuration schemas.
|
|
46
|
+
- Dashboard hosts must expose the exact method, route, response content type,
|
|
47
|
+
and required fields declared by `host-adapter-v2`. Create sanitized runtime
|
|
48
|
+
observations and validate them with `maggie dash conformance`; a static
|
|
49
|
+
component scan is not evidence that a screen works. Astro hosts that exclude
|
|
50
|
+
underscore filesystem segments must rewrite `/_maggie/section-preview` once
|
|
51
|
+
to the internal preview route without disabling origin checks.
|
|
52
|
+
|
|
53
|
+
For API Pull, retain only bounded provider messages and effective quota values
|
|
54
|
+
in lifecycle state. A quota-exhausted response means daily delivery remaining
|
|
55
|
+
is zero even if an older allowance field says otherwise; raw provider bodies
|
|
56
|
+
never enter `.maggie` reports.
|
|
38
57
|
|
|
39
58
|
## Ordered workflow
|
|
40
59
|
|