n8n execution 1700: mode=trigger, status=success, started 2026-10-16 19:00Z (06:00 AEDT 2026-10-17). 8/8 rows 27-34 delivered, scans ok for both children. make verify-n8n exits 0. Documents the acceptance evidence.
224 lines
11 KiB
Markdown
224 lines
11 KiB
Markdown
# 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.
|
||
|
||
## First automatic run — green (2026-10-17)
|
||
|
||
The Schedule Trigger fired on its own at **06:00 Australia/Melbourne on 2026-10-17**
|
||
(`2026-10-16T19:00Z`) and the run was green. This closes the acceptance for AUD-115.
|
||
|
||
- n8n execution **1700**: `mode=trigger`, `status=success`, started
|
||
`2026-10-16 19:00:00` UTC (**06:00 AEDT**). The `mode=trigger` is the point: it is
|
||
the scheduler, not a hand run.
|
||
- `make verify-n8n` (`deploy/verify-n8n-scheduled-run.sh`) exited **0**: the workflow
|
||
is active, the newest scheduled run is green, rows above.
|
||
- Scan at 06:00: Harry `ok`, Finn `ok`.
|
||
- Delivery rows **27–34** — 8/8, all `ok` with `sent_at` set:
|
||
Finn email 27 / WhatsApp 28, Harry email 29 / WhatsApp 30, combined Jo email 31 /
|
||
WhatsApp 32, combined Sam email 33 / WhatsApp 34.
|
||
- `docker exec compass-teams-nhs /app/school notify status`: `total=34 delivered=34
|
||
failed=0`. The last attempt is `combined Sam/whatsapp ok=true at 2026-10-17 06:01`.
|
||
- `POST /notify/morning?dry_run=1` → **200**.
|
||
|
||
The failure path (a non-zero exit makes the execution red and opens the brief with
|
||
`SCAN FAILED`) was proven in the throwaway rehearsal; the live 06:00 run was green, so
|
||
it was not exercised. The Code node is unchanged. The workflow timezone is honoured:
|
||
the n8n container runs `TZ=Australia/Melbourne`, the workflow sets
|
||
`settings.timezone=Australia/Melbourne`, and 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.
|
||
|
||
Earlier, an issue monitor fired early (a manual `monitor/check-now` clears the monitor
|
||
as it dispatches); once re-armed for 06:30 AEDT it woke the assignee on schedule, and
|
||
that run recorded the evidence above.
|
||
|
||
## 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.
|