ops(archive): quota, deploy, pdf TASK/REPORT pairs verified+archived

This commit is contained in:
2026-08-29 06:03:05 -05:00
parent 23ca361025
commit 725c184511
15 changed files with 394 additions and 2 deletions
+159
View File
@@ -0,0 +1,159 @@
# REPORT — Quota monitoring + back-pressure + usage mgmt + resource gate (Redmine 490+491)
- **When**: 2026-08-29 ~05:00-05:50 CST
- **Repo**: `projects/meta/MOPAC/harness` @ `fc518c4` (pushed to origin main)
- **Spec**: `docs/SPEC-20260829-charles-brief.md` — "It's a marathon" + Roadmap 1
- **Gate**: `./dev.sh check` (build + vet + test) clean, all packages `ok`
(config, quota, loop, serve, events, intake, llm, models, tools, writeback)
## 1. Decision: Redis container (not LiteLLM-native)
**Chosen: one Redis container, spoken to by a stdlib RESP2 mini-client.**
Rationale:
1. **LiteLLM tracks dollars, z.ai meters credits.** The thing we must
share is the coding-plan 5-hour + weekly CREDIT buckets with peak
multipliers (input x6.9 + cached x1.7 + output x24 per 10k tokens;
flash 2.3/0.56/8; off-peak 50% off). LiteLLM's budget system is
per-virtual-key $ spend against budgets it enforces itself — a lossy
double-accounting that breaks on every plan change and cannot express
"two harness accounts share one z.ai key".
2. **We own the write path anyway.** With no z.ai usage endpoint (see
§2), consumption is OUR estimate; a store only we write to is the
natural fit. Doing it "in LiteLLM" would mean SQL against another
project's schema — an upgrade hazard, not an integration.
3. **9 instances, 2 hosts, one account each**: TTL'd rolling-window
counters (`INCRBYFLOAT` + `EXPIRE`), snapshot publish/read, atomic
cross-instance — this is exactly Redis. Postgres via LiteLLM gives
us none of those primitives for free.
4. **Charles's constraints honored**: docker container only
(`redis:7-alpine`, runbook in README; bind to 192.168.3.78:6390),
config in `harness.toml` (`redis_url`), zero host packages, zero Go
dependencies (the repo remains stdlib-only; the RESP2 client is
~150 lines and fake-server tested).
5. **Fail-soft**: redis down = this instance's local estimate, loop
keeps running. LiteLLM-native would couple the loop's health to the
proxy's DB.
Keys: `mopac:quota:<account>:{snapshot, est:5h:<window>, est:weekly:<week>}`.
LiteLLM $-spend budget keys remain a separate, still-open item (README
status table says so honestly).
## 2. z.ai usage endpoint findings (LIVE VERIFICATION: OPEN)
Probed 2026-08-29 ~05:05 with the real plan key (from the litellm
container env; never logged, never written to disk):
- `GET /api/coding/paas/v4/{usage,limits,info,quota,credit/usage,...}`
-> **404** (JSON 404 from the gateway, so the route does not exist)
- `/api/coding/paas/v4` (the live chat endpoint LiteLLM uses) is real
and serving — the 404s are route-level, not auth or reachability.
- `/api/coding/{user/info,usage}` -> 200 with `{"code":500,"msg":"404
NOT_FOUND"}` — gateway routes, inner services absent.
- Docs sweep (`docs.z.ai` llms.txt + devpack pages): the plan DOCS the
buckets in detail (5h + weekly credits per tier, multipliers,
off-peak discount, reset rules) and points humans at the billing web
page for consumption — **no public usage REST route is documented.**
Per the task's fallback: the parser targets the documented bucket model
(strict decode; unknown shapes error so an HTML error page can never
read as "quota fine"; alias-tolerant field names), is table-driven
tested against a fake server (bearer header asserted, key-leak
asserted), and `usage_url` in `[quota]` flips it on the day z.ai ships
one. Until then the gate runs on locally estimated consumption from the
documented credit formula — which is exactly the number the loop
controls anyway.
## 3. Peak window — Charles's guess verified
z.ai docs: **peak = Mon-Fri 14:00-18:00 Singapore (UTC+8), off-peak
50% off.** 14:00 SGT = 01:00 CST (winter, UTC-6) / 00:00 CDT (summer,
UTC-5). So "0100 to 0500 CST" is correct for winter and one hour early
during US DST — config carries the window + `timezone` + `peak_weekdays_only`
(defaults `01:00`-`05:00` America/Chicago, weekdays only), and the
report notes the March/November drift. Schedule logic is table-tested
across the edges: window boundaries, weekend exclusion, overnight-wrap
windows (weekday check on the window's start day), and zone-vs-instant
semantics.
## 4. Config surface (see `harness.toml.example` + README table)
- `[quota]`: `enabled`, `account`, `plan_5h_credits` (28000),
`plan_weekly_credits` (140000), `usage_url`+`key_ref`+`poll_interval_secs`,
`defer_at_pct` (85) / `block_at_pct` (95), `peak_start`/`peak_end`/
`timezone`/`peak_weekdays_only`, `peak_classes` (study, read),
`redis_url`.
- `[resources]`: `enabled`, `max_load_avg` (6), `min_mem_available_mb`
(2048), `min_disk_free_mb` (5120), `max_io_delay_pct` (90), test seams
`proc_root`/`sys_root`.
- Both **off by default** (existing configs unchanged — test-asserted).
- CLI: `harness quota status | probe | gate`.
## 5. How the loop behaves at quota exhaustion (the 19:00 wall, replayed)
`TestLoopQuotaWallDefersAndRecovers` (fake Redmine + fake LLM + fake
usage endpoint at 97% weekly):
1. Scan sees the issue -> gate decides DEFER -> **zero LLM calls**, one
stdout line + one `"type":"defer"` JSONL event with the reason
("quota: weekly bucket at 97% (>= block 95%): all classes deferred
until reset").
2. The task is **not consumed** (no dispatch marker, no note) — defer is
a throttle, so quota recovery needs no human touch.
3. Endpoint flips healthy -> next scan dispatches normally, notes the
REPORT, status transitions. The 19:00-class failure no longer
exists: pre-wall, the loop runs LLM-lite classes only past
`defer_at`; at `block_at` everything pauses, surfaced, until reset.
Decision order: resources -> block wall -> peak-window class
restriction -> soft-quota heavy deferral. Never a hard fail; unknown
quota state is permissive.
## 6. Usage accounting (feeds the Discourse reports)
Every dispatched turn appends `class`, `prompt/completion/total_tokens`,
and estimated `credits` to its `report` event in `loop.jsonl`;
`loopState` rebuilds per-class totals from the JSONL at startup, and
`harness quota status` renders the table (turns/tokens/credits per
class + TOTAL). Also verified live in-container: real `/proc` reads
(load 13.43, PSI io 0.3%, disk 44.9GB) and correct TZ evaluation.
## 7. Test results (TDD, table-driven, fake clock/servers)
- `internal/quota`: parser (canonical + aliases + hostile bodies),
schedule edges (TZ, wrap, weekdays), credit math (peak/off-peak,
flash/flagship, unknown-model conservatism), decision levels (healthy
/ defer / block / peak interactions), estimate snapshots (redis +
local), fake-redis RESP2 server round-trips, dead/nil-state fail-soft,
resource fixtures (PSI present/absent, unreadable).
- `internal/loop`: wall defer+recover, peak heavy-defer + flash-run +
off-peak release, resource-busy defer, usage accounting JSONL +
table, gate-off-by-default regression.
- `internal/config`: [quota]/[resources] load + 7 validation-error
cases; TOML float support.
- Docker dev discipline throughout (`./dev.sh` only); host untouched.
## 8. Resource gate (491) + cgroup runbook
Read-only monitor (loadavg, MemAvailable, statfs disk free,
`/proc/pressure/io` some-avg60 — skipped when PSI absent, read errors
never defer). cgroup enforcement documented as deploy-time in the README
runbook (`--memory/--cpus/--pids-limit` / systemd slice), per Charles's
"docker + cgroups for all work" rule.
## 9. Open items
- **LIVE VERIFICATION open**: z.ai usage endpoint (flip `usage_url`
when it ships; parser + probe command ready).
- Weekly bucket reset is approximated to Monday 00:00 plan-TZ until
`reset_at` arrives from a real endpoint.
- Turn `cached_tokens` not yet captured from LiteLLM responses (LLM
client returns prompt/completion only) — credits currently
conservative (cache discount not credited).
- Redis container not yet deployed to 192.168.3.78 (runbook written;
`enabled = false` defaults mean nothing regresses until then).
- LiteLLM $-spend budget keys (separate from credit buckets) still
phase 3.
— PMO worker, MOPAC harness self-host loop
+118
View File
@@ -0,0 +1,118 @@
# REPORT — Multi-account deploy packaging + runbook (Redmine 494)
- **When**: 2026-08-29 ~05:45-06:00 CST
- **Repo**: `projects/meta/MOPAC/harness` @ `b7799ea` (pushed to origin main)
- **Spec**: `docs/SPEC-20260829-charles-brief.md` — account list (9 Linux
accounts / 2 hosts), "no root on target accounts" PMO constraint
- **Gates**: `./dev.sh check` clean (all 11 Go packages ok, untouched);
`make deploy-test` **13/13 ok**; `make release` verified static +
end-to-end smoke (install COSWFO in a fake HOME -> `once --dry-run
--demo` exit 0 via the actual release binary)
## 1. What Charles executes (the whole deployment)
Authority: `deploy/runbook.md` + `deploy/accounts.tsv` in the repo.
1. **Build once** (workstation, ~2-3 min): `make release` ->
`bin/harness-linux-amd64` (digest-pinned docker builder, CGO off,
linux/amd64, static + stripped — `file` says "statically linked").
2. **Stage per host** (~1 min each): `tar czf /tmp/mopac-deploy.tgz
deploy bin/harness-linux-amd64` + one `scp` per host.
3. **Install per account** (~30s each, idempotent): ssh in, then either
direct (`install-account.sh reachableceo`) or via identity switch
(`sudo -u TSGBOD -H sh -c '... && install-account.sh TSGBOD'` — sudo
is identity-switch only, nothing system-wide). The installer creates
`~/.mopac/{bin,state/loop,state/events,reports,work}`, installs the
binary, renders `harness.toml` from the template + TSV row, writes the
0600 `~/.mopac/env` secrets template, generates `mopac-start`/`mopac-stop`.
4. **Secrets bootstrap** (~1 min/account): fill `~/.mopac/env` from
Bitwarden (Redmine key, LiteLLM vkey, 3 webhook secrets, serve vkey).
Configs stay secret-free (env: refs only).
5. **Verify then start** (~25s/account): see §3.
6. **Reboot persistence**: cron `@reboot` line (documented verbatim) or
manual re-run of the idempotent `mopac-start`. **No systemd** — that
needs root, which the accounts don't have; both options documented.
## 2. Concurrency guard (why 9 daemons per host can't collide)
Port scheme from `accounts.tsv` (index is global 0-8, listed order):
`events = 4100 + index`, `serve = 8090 + index`.
| account | host | events | serve | | account | host | events | serve |
|---|---|---|---|---|---|---|---|---|
| reachableceo | streaming | 4100 | 8090 | | reachableceo-offstage | offstage | 4105 | 8095 |
| TSGBOD | streaming | 4101 | 8091 | | COSRCEO-Personal | offstage | 4106 | 8096 |
| TSGCOO | streaming | 4102 | 8092 | | COSRCEO-Biz | offstage | 4107 | 8097 |
| TSGCTO | streaming | 4103 | 8093 | | COSWFO | offstage | 4108 | 8098 |
| TSGCCO | streaming | 4104 | 8094 | | | | | |
The loop daemon has no port; its state (`state/loop`, `state/events`) is
per-account under `~/.mopac/`. The installer hard-asserts the scheme per
row (dies if `events != 4100+index`), and `deploy/tests.sh` re-asserts
fleet-wide (host,port) uniqueness.
## 3. Verification steps (per account, runbook step 5+6)
1. `~/.mopac/bin/harness once --dry-run --demo -config ~/.mopac/harness.toml`
— no secrets, no LLM call; must exit 0 printing the PLAN with the
account's vertical (this exact path is what the release smoke ran).
2. `. ~/.mopac/env && ~/.mopac/bin/harness loop --once --dry-run -config ...`
— first real Redmine scan of the account's scope (needs the Redmine
key): prints what would dispatch, writes nothing.
3. `mopac-start`, then healthz on the account's two ports
(`curl http://127.0.0.1:<events>/healthz`, `<serve>/healthz`) and an
authenticated `GET /v1/models` through the serve vkey; `tail
~/.mopac/state/loop.log` for scan lines.
## 4. Rollback (per account, ~30s)
`~/.mopac/bin/mopac-stop` (SIGTERM, clean) -> `rm -rf ~/.mopac` (or `mv`
aside to keep evidence) -> drop the cron `@reboot` line if used. Every
trace of an instance lives under `~/.mopac/`; Redmine/Gitea data is
untouched by removal.
## 5. Time estimate (the Charles window)
| | streaming (5 accts) | offstage (4 accts) |
|---|---|---|
| stage + install | ~3 min | ~2.5 min |
| secrets bootstrap | ~5 min (unless prefilled) | ~4 min |
| verify + start + healthz | ~2 min | ~1.5 min |
| **total** | **~10 min** (mechanical only: ~5) | **~8 min** (mechanical: ~4) |
Build + Redmine project bootstrap happen before the window.
## 6. Tests (deploy/tests.sh, 13 assertions, all green)
TSV: 9 rows, spec-exact account/host sets, unique accounts + (host,port)
pairs, `events=4100+idx`/`serve=8090+idx` on every row. Installer: render
all 9 accounts into fake HOMEs (no leftover `@PLACEHOLDER@`s, correct
vertical/ports/project/absolute paths, env mode 0600, helpers executable
with the right ports); idempotent re-run (exit 0, config+env
byte-identical); hand-edited `harness.toml` survives re-runs; unknown
account exits non-zero; staged-bundle binary lookup works; rendered TOML
actually loads (`once --dry-run --demo`, exit 0). Plus the live
release-binary smoke described above.
## 7. Assumptions flagged in the runbook (one-line fixes, no redeploy)
- **Redmine project identifiers** `mopac-<vertical>` x9 must exist (or
edit the TSV column before staging / the `scope_query` after install —
installer never overwrites an existing config).
- **Quota grouping** assumes one z.ai Max plan per host
(`zai-max-1`/`zai-max-2`); `[quota]` ships commented with values
pre-filled — confirm grouping, then flip `enabled = true` per account.
- `[redmine.status_map]` ships empty (workflow names are per-project;
the generated file documents the Released->Done pair to set).
- Host short names assumed `ultix-streaming`/`ultix-offstage`; the
installer prints an advisory on mismatch, never blocks.
## 8. Open items
- Actual ssh/scp execution is Charles's window (PMO runtime cannot
ssh/sudo) — everything is scripted, tested locally, and pushed.
- Webhook registration in Redmine/Discourse/Gitea per account (URLs +
secrets) is post-install config, not packaging; noted in runbook step 0.
- LiteLLM virtual keys per account assumed to exist on 192.168.3.78:4001.
— PMO worker, MOPAC harness self-host loop
+79
View File
@@ -0,0 +1,79 @@
# REPORT-20260829-1300-pdf — mopac-pdf v0 (Redmine 499)
Status: DONE. ukrrs/mopac-pdf created (Gitea API/tea), seeded AGPLv3,
implemented, tested, smoked, pushed to `main`
(https://git.knownelement.com/ukrrs/mopac-pdf). Clone at
`~/projects/meta/MOPAC/pdf`.
## Engine decision: typst (digest-pinned), pandoc+LaTeX rejected
Full rationale + measurements committed as `docs/ENGINE-DECISION.md` in
the repo. Summary, measured 2026-08-29 on identical content (TOC + table +
chart figure):
| | typst 0.15.1 | pandoc 3.5 + xelatex |
|---|---|---|
| compile | 0.94 s | 7.66 s (~8x slower) |
| image | ~200 MB | 758 MB |
| templates | typed functions, `#show: report.with(...)` | LaTeX preamble/class surgery, multi-pass TOC |
| output | modern typography out of the box | classic LaTeX look |
typst also natively streams `compile doc.typ -` to stdout, which keeps our
engine layer pure bytes-in/bytes-out (no root-owned files, no temp
collisions). Supply chain per Charles' clarification: prebuilt Rust binary
in a digest-pinned image is tooling; OUR code is Go only.
Pin: `ghcr.io/typst/typst@sha256:032e292...9c4c422f` (= 0.15.1,
cross-checked), run `--network none` with a private `/work` root.
## What shipped (v0)
- CLI `mopac-pdf`: markdown + front-matter (title/subtitle/author/date/
classification/template) -> PDF to stdout or `-o`; `-t`/`-T` template
pick/override (templates embedded in the binary AND loadable from a
dir); `-pages` helper; exit codes 0 ok / 1 usage-input / 2 engine.
- Templates: `report` (title page, TOC, numbered headings, running header,
page X/Y + classification footer) and `brief` (dense 1-3 pager, compact
title block, classification badge, small tables).
- Markdown subset the whole stack actually emits: headings, paragraphs,
inline bold/italic/code/links, ul/ol, blockquotes, hr, GFM tables with
alignment, fenced code, block images.
- Charts: fenced ```chart data blocks -> bar charts rendered PURE GO
(vendored go-chart, MIT) -> PNG figures. One type proven end to end,
per v0 scope.
- Dev in docker only: `dev.sh`/`make` route through the family builder
digest; deps vendored (hermetic); smoke drives the REAL typst container
from the host (11 checks: both templates, stdin/stdout, page counts,
exit codes, custom template dir — all green).
- Tests: 7 packages ok — front-matter table tests, parser tests, golden
.md -> .typ fixtures (drift-catching), chart PNG decode, tiny
/Pages /Count parser tests, engine argv contract via stub docker, CLI
flag/exit-code matrix.
## Sample outputs (local, regenerable via ./dev.sh smoke)
- ~/projects/meta/MOPAC/pdf/out/sample-report.pdf — 4 pages (title, TOC, body + table + chart)
- ~/projects/meta/MOPAC/pdf/out/sample-brief.pdf — 1 page (dense exec brief)
- ~/projects/meta/MOPAC/pdf/out/stdin-report.pdf — stdin->stdout path
- fixtures live in-repo: testdata/sample-{report,brief}.md (+ .typ.golden)
## How the briefing pipeline will call it
The COS briefing/harness side already speaks markdown; the contract here
is a single exec: `mopac-pdf -o <path>.pdf <brief.md>` (or pipe bytes:
`mopac-pdf < brief.md > brief.pdf`). Front-matter carries the identity
(title/date/classification/template=brief for the 0630 morning brief,
report for long-form), so callers never touch typst. Failure semantics
are harness-shaped: 0/1/2, engine stderr surfaced verbatim, no partial
output files on failure. Engine image + docker binary overridable by env
for canary/migration. No daemon, no state — a pure function, safe to call
from any loop turn.
## Next (proposals, not started)
- More chart types (line, donut) — renderer is a one-function extension
point; needs a data-block convention for series.
- More templates: letterhead, invoice, slide-deck-ish landscape.
- Nested lists >1 level; footnotes; typst-side escape hardening fuzz.
- Optional direct embedding of briefing charts without PNG round-trip
(emit typst vector drawing for crisp print) once typst scripting
surface stabilizes.
+54
View File
@@ -0,0 +1,54 @@
# TASK: Quota monitoring + back-pressure + usage mgmt + resource gate (Redmine 490+491)
## Context
Charles's spec of record: `docs/SPEC-20260829-charles-brief.md` (READ IT
FIRST, esp. "It's a marathon" + Roadmap 1). Last night the z.ai quota wall
killed two dispatched turns at 19:00 — this system prevents that class of
failure and maximizes the flat-rate plan 24x7 (legacy plan ends 9/22).
Redmine tickets: 490 (quota) + 491 (resource monitor).
## Scope — `internal/quota` + `harness quota` + loop integration
1. **z.ai usage polling**: research the z.ai coding-plan usage/limits
endpoint (it exists per Charles; find the API surface from public docs
+ the z.ai provider behavior we already see). Poll on interval; parse
buckets/limits/reset times; expose `QuotaSnapshot` (struct: per-bucket
used/limit/window-reset).
- Auth via key_ref (never logged, same discipline as keyproxy).
- If the endpoint proves unreachable/undocumented: build against a
documented interface + fake server, flag LIVE VERIFICATION as open.
2. **Central state decision** (Charles leaves it to us): Redis container
vs LiteLLM-native (postgres spend tracking already live at
192.168.3.78:4001). Decide with rationale in the REPORT; implement the
chosen one. Requirements: multiple harness instances (9 accounts, 2
hosts) share quota state; redis (if chosen) runs as a docker container
on this host, config in harness.toml, no host packages.
3. **Back-pressure in the loop** (internal/loop): before dispatching a
turn, consult quota snapshot + schedule:
- Peak window (config, default 0100-0500 CST — VERIFY empirically if
possible; Charles sleeps 2300-0500): restrict to flash-tier classes;
defer heavy classes with a logged reason; prefer LLM-lite work.
- Quota nearly exhausted: defer + surface status; never hard-fail the
loop.
- Schedule + thresholds all in `[quota]` harness.toml section (TZ-aware,
CST default).
4. **Resource gate (ticket 491)**: read-only system monitor (load avg,
mem available, disk free, IO delay from /proc + /sys) with
configurable busy thresholds; loop defers dispatch when busy. cgroup
enforcement is deploy-time — document the runbook section only.
5. **Usage accounting**: per vertical/class token+model accounting into
the JSONL state (extend loop state) — feeds the eventual
per-instance Discourse usage reports.
6. TDD red/green: quota parser, schedule logic (peak/offpeak across TZ
edges), back-pressure decisions, resource thresholds — table-driven +
fake clock. Docker dev discipline. Docs standard (README + config
table). Push to origin main.
## Deliverable
`REPORT-20260829-0500-quota.md` in `~/.coordinate/inbox-pmo/`: the Redis
vs LiteLLM decision + rationale, endpoint findings, config surface, test
results, and how the loop now behaves at quota exhaustion (the 19:00-wall
scenario replayed as a test).
## ADDENDUM (redispatch, ~05:10 CST)
- Previous attempt died on a sourcegraph.com timeout — do NOT use sourcegraph; research the z.ai usage endpoint via direct fetch of z.ai docs / the API itself.
- BE QUOTA-LEAN (weekly bucket at 93%): minimal exploration, write code in large correct chunks, one test pass, no gold-plating, finish and report.
+43
View File
@@ -0,0 +1,43 @@
# TASK: Multi-account deploy — packaging + runbook, 9 accounts / 2 hosts (Redmine 494)
## Context
Spec: docs/SPEC-20260829-charles-brief.md (account list inside). The PMO
runtime CANNOT ssh or sudo — this turn produces everything so that
deployment is a ~10-minute Charles window executing one runbook.
## Accounts
- ultix-streaming: reachableceo, TSGBOD, TSGCOO, TSGCTO, TSGCCO
- ultix-offstage (ssh reachableceo-offstage, passwordless sudo there):
reachableceo-offstage, COSRCEO-Personal, COSRCEO-Biz, COSWFO
## Scope
1. `deploy/` directory in the harness repo:
- `deploy/accounts.tsv` — account, host, vertical, redmine project
scope, port assignments (events/serve per account: derive a scheme,
e.g. base port 4100+ / 8090+ offset by account index).
- `deploy/install-account.sh <account>` — idempotent, runs AS the
target user (no root needed if binary path is ~/.local/bin): creates
~/.mopac/{bin,state,reports}, installs static binary (built in the
Docker builder for linux/amd64), writes harness.toml from a template
(per-account substitutions), refuses to overwrite existing
harness.toml or secrets.
- `deploy/runbook.md` — the exact Charles sequence: build once
(docker), copy/ssh loop per host+account, run install script, start
command per account (nohup or cron @reboot line — NO systemd claims
without root; document both options), verify (healthz curl-equivalent
+ first `loop --once` dry output), rollback (stop + rm ~/.mopac/bin).
- Per-account harness.toml template with vertical/redmine-scope/ports
substituted; secrets stay env refs (0600 env files per account,
bootstrap instructions).
2. Static binary build: Makefile target `release` (docker builder,
GOOS=linux GOARCH=amd64, CGO=0) producing `bin/harness-linux-amd64`.
3. Concurrency guard: distinct state dirs + ports per account so multiple
daemons on one host never collide; document the account-port table.
4. Tests where feasible (template substitution, port derivation,
idempotence of install script in a fake HOME). Docs standard.
## Deliverable
Commits pushed to ukrrs/MOPAC main (deploy/ + Makefile release target +
README deploy section). `REPORT-20260829-0900-deploy.md` in
`~/.coordinate/inbox-pmo/`: what Charles executes, per-host time estimate,
verification steps, rollback.
+48
View File
@@ -0,0 +1,48 @@
# TASK: mopac-pdf v0 — beautiful PDF generation pipeline (Redmine 499)
## Context
Spec docs/SPEC-20260829-charles-brief.md "Workflows": beautifully
formatted PDFs of budgets, travel itineraries, project plans, proposals,
consulting reports, multi-year financial plans, business plans,
engineering docs — with charts/diagrams. This feeds the COS briefing
world (reports) and TSYS client deliverables.
## Constraints
- OUR code: Go only (no python/node/rust — prebuilt engine BINARIES in
digest-pinned docker images are tooling, fine).
- ALL DEV IN DOCKER. AGPLv3. Docs standard.
## Scope
1. New repo `ukrrs/mopac-pdf` (create via Gitea API if absent — PMO
pattern, tea token; clone to ~/projects/meta/MOPAC/pdf). LICENSE
AGPLv3 first commit.
2. Engine decision (document rationale in REPORT): typst (docker image,
typst compile) vs pandoc+latex — evaluate compile speed, chart/table
quality, template ergonomics. Typst expected to win on modern output.
3. Go package + CLI `mopac-pdf`:
- Input: markdown (the composable currency of this whole stack —
Redmine notes, Discourse posts, briefing output) + front-matter
(title, subtitle, date, classification footer, template name).
- Templates dir: 2 shipped v0 — "report" (clean business report:
title page, TOC, headers/footers, page numbers) and "brief"
(1-3 page exec summary, dense tables).
- Charts: pure-Go rendering option (go-chart or similar permissive
lib) embedded as images from fenced code blocks with data blocks;
keep v0 minimal — one chart type proven end-to-end.
- Output: PDF bytes to stdout or -o file; exit codes like harness
(0/1/2 usage/engine).
4. Docker runner: `mopac-pdf` shells the engine container (digest-
pinned); host stays toolchain-free. Makefile/dev.sh per family
pattern.
5. Tests: template rendering golden tests (fixture .md -> PDF, assert
non-empty + page count via a tiny Go PDF header parse or engine
stdout), CLI flags, front-matter parsing. NEVER broad pkill.
## Deliverable
Commits pushed to ukrrs/mopac-pdf main.
`REPORT-20260829-1300-pdf.md` in `~/.coordinate/inbox-pmo/`: engine
decision + rationale, sample outputs listed (paths), how the briefing
pipeline will call it, what's Next (chart types, more templates).
> CLARIFIED (Charles): supply chain MAY include prebuilt Rust tools (typst
> etc.) in digest-pinned images. OUR code: Go/PHP/C/Java only.