Files
compass_teams_nhs/docs/phase-7a-n8n-schedule.md
Sam Rolfe 11155df1fe Phase 7a: read-only verifier for the 06:00 scheduled run (AUD-115)
make verify-n8n / deploy/verify-n8n-scheduled-run.sh checks the workflow is
active, reads the newest scheduled (mode=trigger) execution from the n8n
database, and prints the notify rows. Exit 1 until a scheduled run exists and
is green, so it distinguishes 'not yet' from 'failed'. Never runs the workflow
and never sends. Document the first-run time and the re-armed monitor.
2026-10-16 10:31:40 +11:00

206 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7a — the 6:00am n8n schedule (AUD-115)
Phase 7 (AUD-111) proved the delivery path: `POST /notify/morning`, the per-boy and
combined briefs, the Apprise channels, and eight real deliveries. This phase makes
the schedule real: one n8n workflow that runs every day at 06:00 and sends the
briefs.
## What the workflow is
`deploy/n8n-morning-brief.json` is a four-node workflow:
```
Schedule Trigger "0 6 * * *" (Australia/Melbourne) ─┐
├─▶ SSH node ──▶ Code check
Manual Trigger "Run now (manual)" ───────────────────┘ (restricted key)
```
- **SSH node** — authenticates to `.13` as `sam` with the restricted trigger key and
runs the brief on the host. The scan needs node + patchright on `.13`, so it cannot
run inside the n8n container.
- **Code node** — the SSH node returns the remote exit code but does not fail on it.
The Code node throws when `code !== 0`, so a failed scan or send turns the n8n
execution red (FR-12).
- **Manual Trigger** — optional. It lets Sam run the brief by hand from the n8n UI, or
with `docker exec n8n n8n execute --id=schoolMorningBrf`. The schedule fires on its
own; the manual trigger only does something when pressed.
`scripts/morning-brief.sh` is the bounded command. It scans both children, builds the
per-boy and combined briefs, sends them through Apprise, and exits non-zero when the
scan or a send fails. The brief always opens with the failure when the scan failed —
never "nothing due".
The run needs **no** `SCHOOL_NOTIFY_TOKEN` in n8n. The token stays in
`environment.d/school-service.env`, which the host script reads.
## Why not an Execute Command node
The first design used one `n8n-nodes-base.executeCommand` node. **It cannot work on
this instance.** n8n 2.x disables that node by default for security
(`nodes.exclude = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger']`).
An imported workflow with it is marked active in the database but never activates:
```
Activation of workflow "Compass / NHS — 6:00am morning brief" (schoolMorningBrf) did fail
with error: "Unrecognized node type: n8n-nodes-base.executeCommand"
```
The same error affects the pre-existing `Tree Info` workflow. The SSH node is enabled
and, with the restricted key, runs exactly one command — so it is both workable and
least-privilege. Enabling Execute Command instead would need `NODES_EXCLUDE=[]` in the
shared n8n service, which re-enables arbitrary shell execution for every workflow.
## The SSH trigger key and credential
The n8n container mounts `/home/sam/.ssh` at `/home/node/.ssh`. The trigger uses a
dedicated key with a forced command, so it can run exactly one thing:
```
restrict,command="cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/morning-brief.sh" ssh-ed25519 AAAA…
```
- `restrict` disables pty, agent forwarding, port forwarding, X11, and shell access.
- `command=` makes the server ignore whatever command the client sends.
- The private key lives at `/home/sam/.ssh/id_ed25519_school_scan` (mode 0600) and is
readable only by `sam` and the n8n container (which already mounts `~/.ssh`).
- The key is not Sam's personal key and is not used anywhere else.
Create the key with:
```bash
deploy/setup-n8n-scan-key.sh # dry run: print the plan
deploy/setup-n8n-scan-key.sh --apply # create the key + the authorized_keys line
```
The SSH node needs the private key in an n8n credential (`sshPrivateKey`, id
`schoolScanKey`). `deploy/import-n8n-morning-brief.sh` builds that credential from the
key file into a mode-0600 temp file, imports it, and removes it. The key is never
written into this repository.
## Import and activate
```bash
deploy/import-n8n-morning-brief.sh # dry run: print the plan
deploy/import-n8n-morning-brief.sh --apply # credential + workflow, publish, restart n8n
```
The script imports the credential, copies the JSON into the container with
`docker exec -i … sh -c 'cat > …'` (`docker cp` fails against the `~/.ssh` mount),
runs `n8n import:workflow`, then `n8n publish:workflow --id=schoolMorningBrf` (n8n 2.x
replaced `update:workflow --active`), restarts n8n once so the 06:00 schedule
registers, and confirms the **n8n log** shows the workflow activated — the database's
active flag alone is not proof.
The JSON carries a stable `id` (`schoolMorningBrf`). n8n 2.18.5 refuses an import
whose workflow has no `id` (`SQLITE_CONSTRAINT: NOT NULL constraint failed:
workflow_entity.id`), so the id is required, not cosmetic. Re-importing the same file
updates the same workflow instead of adding a second copy.
`n8n publish:workflow` prints *"Changes will not take effect if n8n is running"*, which
is why the import script restarts n8n once. The full import → activate → run and the
failure path were rehearsed in a throwaway n8n container with a temporary data
directory, using the same n8n 2.18.5 image, before the change was proposed. The shared
instance was not touched for the rehearsal.
## Verification
```bash
docker logs n8n | grep 'Activated workflow "Compass / NHS'
docker exec n8n n8n list:workflow --active=true # schoolMorningBrf is listed
docker exec compass-teams-nhs /app/school notify status
```
Rehearsal evidence (throwaway container, same image):
- activation: `Activated workflow "school ssh rehearsal"` — no `Unrecognized node type`
- success path (forced command echo): `status: success`
- failure path (forced command `exit 7`): `status: error`, CLI exit 1, message
`morning-brief.sh failed with exit 7`
A healthy morning looks like this in `notify status`:
```
1 Harry Harry email ok <date> 06:0x
2 Harry Harry whatsapp ok <date> 06:0x
3 Finn Finn email ok <date> 06:0x
4 Finn Finn whatsapp ok <date> 06:0x
5 combined Jo email ok <date> 06:0x
6 combined Jo whatsapp ok <date> 06:0x
7 combined Sam email ok <date> 06:0x
8 combined Sam whatsapp ok <date> 06:0x
```
## Proving a run
The Schedule Trigger only fires at 06:00, so the schedule is proved by the activation
log line plus the next morning's `notify status` rows. To prove the full send path
without waiting, run the published workflow once by hand:
```bash
docker exec -e N8N_RUNNERS_BROKER_PORT=5681 n8n n8n execute --id=schoolMorningBrf
```
The `N8N_RUNNERS_BROKER_PORT` override gives this short-lived CLI process its own task
broker port. Without it `n8n execute` stops with *"n8n Task Broker's port 5679 is
already in use"*, because the running n8n instance owns 5679. It changes only the one
`docker exec` process; the shared service keeps 5679.
This sends the real briefs, so run it only when a real delivery is wanted.
## Verify the scheduled run (read-only)
```bash
make verify-n8n # or: deploy/verify-n8n-scheduled-run.sh
```
`deploy/verify-n8n-scheduled-run.sh` never runs the workflow and never sends
anything. It checks that the workflow is active, reads the newest **scheduled**
(`mode=trigger`) execution from the n8n database, and prints the notify rows. A hand
run (`mode=cli`) does **not** satisfy it. The exit code distinguishes the states:
- `0` — a scheduled run exists and its newest one finished `success`.
- `1` — the workflow is inactive, or the schedule has not fired yet, or the newest
scheduled run failed.
It reads the n8n database directly (`N8N_DATA_DIR`, default
`/home/sam/Docker/Containers/n8n_data`) because n8n 2.x stores scheduled executions
with `mode=trigger`, which `n8n list:workflow` does not report.
## Applied — 2026-10-16
Sam accepted plan revision 2 (`257b4b0a`), and the change was applied to the shared n8n
on `.13`. Evidence from that heartbeat:
- `deploy/import-n8n-morning-brief.sh --apply`: imported 1 credential and 1 workflow,
published `schoolMorningBrf`, restarted n8n once, and the log then showed
`Activated workflow "Compass / NHS — 6:00am morning brief" (ID: schoolMorningBrf)`
with no `Unrecognized node type` error. `n8n list:workflow --active=true` lists it.
- A hand run through the published workflow finished `"status": "success"`,
`"finished": true`, CLI exit 0. The SSH node reached `sam@192.168.20.13` with the
restricted key, ran `scripts/morning-brief.sh`, scanned both children (Harry 71
items, Finn 43 items, both `ok`) and delivered 8/8 through Apprise (email +
WhatsApp for Harry, Finn, and the combined brief to Jo and Sam).
- `docker exec compass-teams-nhs /app/school notify status`: `total=26 delivered=26
failed=0`, new rows 19–26 (Harry 19–20, Finn 21–22, combined Jo 23–24, Sam 25–26),
all `ok` with `sent_at` set.
The remaining check is the first automatic run: the Schedule Trigger fires at
**06:00 Australia/Melbourne on 2026-10-17** (`2026-10-16T19:00Z`). The n8n container
runs `TZ=Australia/Melbourne` and the workflow sets
`settings.timezone=Australia/Melbourne`; the pre-existing `AI Calendar - Family
Notifier` schedule stores its local 05:00/16:00/20:00 firings as 18:00/05:00/09:00
UTC, which confirms the workflow timezone is honoured.
The first issue monitor fired too early and was cleared, so it was re-armed for
**06:30 Australia/Melbourne on 2026-10-17** (`2026-10-16T19:30:00Z`) to record the
automatic run's rows with `make verify-n8n`.
## Not touched
- Compass / Teams: read-only. Nothing in this phase writes to the school.
- Other n8n workflows: untouched.
- n8n's global node configuration: untouched. Execute Command stays disabled.
- Secrets: no address, phone number, Apprise key, session secret, or notify token is
written into the workflow JSON, this document, or git. The SSH private key lives in
an n8n credential and the host `authorized_keys`, never in the repository.