Archive location (Gitea issue 7): ARCHIVE_ROOT, Admin file-locations API, archive-copy job, doctor check

A filed packet's sealed data book, documents and a manifest are copied to the archive folder by the worker.
A failed copy never blocks filing: it is retried with backoff and raises the archive_failed condition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-30 17:50:04 -06:00
co-authored by Claude Opus 5.5
parent 3fdf063b70
commit da31b0a87a
21 changed files with 1537 additions and 11 deletions
+31 -1
View File
@@ -88,8 +88,9 @@ At start the worker runs the same stack guard as the app: the test stack refuses
| `backup-check` | Daily at 07:15 site time | no | The newest dump is there, not empty, under 26 hours old, and not followed by a failure (phase 9). |
| `notify-send` | Every 60 seconds | no | Rule seed, the version check, the watch checks, immediate delivery and the digest queue (phase 9). |
| `notify-digest` | Every 5 minutes | no | Sends each digest that is due, one message per address (phase 9). |
| `archive-copy` | Every 15 minutes, plus a request from every filing, every folder save and "Retry now" | no | Copies each filed packet's data book, files and a manifest to the archive folder (phase 10). |
`job_schedule` holds each job's next run, interval and pause state for the Admin tab. Migration 0004 seeds rows for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds the six phase 9 jobs.
`job_schedule` holds each job's next run, interval and pause state for the Admin tab. Migration 0004 seeds rows for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds the six phase 9 jobs; 0012 adds `archive-copy`.
## Phase 9 jobs
@@ -141,6 +142,34 @@ A failed or retried send makes the run degraded.
For each rule whose digest time (site time) has passed today and that has outbox rows queued before it: recipients resolved per event, then grouped per address across every rule. One message per address lists every due event (`Daily digest: N notifications`), with one `notifications_sent` row per message (event type `digest`). An outbox row is marked sent only when every address got it. A failed address leaves the rows for the next run, which skips addresses that already got an event, and the job backs off 30 minutes (open question 43). Nothing queued: the run ends without touching AD.
## archive-copy (every 15 minutes, phase 10)
Gitea issue 7. Not pausable. Off when `ARCHIVE_ROOT` is unset or blank: the run does nothing (counts `enabled: false`) and FILE asks for nothing. With the root set but no folder saved on Admin, File locations, the run idles (`skipped: no archive folder saved`).
FILE inserts a `job_requests` row for `archive-copy` and NOTIFYs `ps_job_request` inside its own transaction when the archive is on and a folder is saved, so the copy starts right after the filing commits. The request is made in a savepoint: if it fails, the filing still commits and the next scheduled run copies the packet. A failed copy never blocks filing and never touches the packet. Saving a folder and "Retry now" also request a run.
Each run:
1. Checks the saved folder with the same rules and probe as the Admin's Check (below). A failed check raises `archive_failed`, makes the run degraded and copies nothing. The job audits its own check (`archive.checked`, source `job`) only when the result differs from the last check on record.
2. Queues every unbroken seal with a data book that has no `archive_copies` row, so turning the archive on later copies earlier filings too.
3. Copies the pending rows, then the failed rows whose `next_try_at` has passed, up to 200 per run. Each copy is audited (`archive.copied` or `archive.copy_failed`). A failed copy waits 5 minutes, then 15, then 60, then an hour between tries. "Retry now" makes every failed row due at once.
4. Raises `archive_failed` (condition, critical, subject `archive`) when any copy has failed 3 times or more, and resolves it when no copy is failed.
Counts: `enabled`, `archive_dir`, `queued`, `copied`, `failed` (this run), `pending`, `failed_total`, `done_total`, and `check` (the message) when the folder check failed. A failed copy makes the run degraded.
**The folder rules.** `ARCHIVE_ROOT` is a fixed path on the server (the NFS mount). The Admin enters the folder relative to it; an absolute path is accepted only under the root and is stored relative. Refused: empty, a `..` component, an absolute path elsewhere, the root itself, a NUL character, more than 400 characters, a folder that does not exist (nothing creates it), something that is not a folder, and a path whose real path (links followed) leaves the real root. **The check** writes `.packetstation-probe-<random>` in the folder, fsyncs it, reads it back, compares and deletes it, and reports the free space. `packetstation doctor` runs the same check on the saved folder (SKIP when the archive is off or no folder is saved).
**What is copied.** One folder per seal: `<archive folder>/<project>/<unit>/<data book name without .pdf>-seal<seal id>/`, for example `packets/L51234/L51234001/L51234-Lot001_D-5001_Manufacturing_Data_Book-seal7/`. In it:
- the sealed data book PDF (the asset on the seal);
- the files of the packet's drawing pages (live and void, each file once);
- the packet documents that are not removed (NCRs and test reports included);
- the current Q.C. folder documents of the packet's unit and its project (what the data book's section 5 reads);
- the MTRs on the BOM lines;
- `manifest.json`: `packet_id`, `packet_label`, `project`, `unit`, `drawing`, `rev`, `seal_id`, `seal_hash`, `key_id`, `sealed_at`, `sealed_by`, `filed_at`, `filed_by`, `build`, and per file `name`, `kind` (databook, page, doc, qc_doc, mtr), `sha256`, `size`, `asset_id`.
File names are the stored original names made safe (no separators or reserved characters) and unique in the folder (`name (2).pdf`). Each file is written to a temp name in the folder, fsynced, checked against the asset's sha256 and renamed; the manifest goes last. An existing identical file counts as done; a different file is never overwritten (the copy fails and names the file). A manifest already there for the same seal and files means the copy is done. Nothing in the archive is ever deleted. A refile after a reopen is a new seal and a new folder.
### Events from the feed and the drawing job
After each `visual-feed` and `drawing-lookup` run, `worker/runs.py` raises or resolves the feed conditions:
@@ -171,6 +200,7 @@ After each `visual-feed` and `drawing-lookup` run, `worker/runs.py` raises or re
| `qc_reject` | A Q.C. reject on a packet, an ITP line reject | one-shot, department | none |
| `version_deployed` | notify-send, first run on a new build | one-shot | none |
| `assets_quota` | The storage check after every upload and in housekeeping | condition | usage back under the threshold |
| `archive_failed` | archive-copy: a copy failed 3 times, or the folder check failed | condition, subject `archive` | no copy failed |
A condition sends once when it opens and a "Cleared: <title>" message to the same rule when it closes. Raised again while open, it only counts (`raised_count`). The department of an ITP reject is the one department among the unit's live packets that works to the line's page; none or several gives none.
+11 -1
View File
@@ -353,7 +353,7 @@ Phase 3, migration 0004:
- `job_requests`: one row per "Run now". `job`, `requested_at`, `requested_by`, `note`, `claimed_run_id` and `claimed_at` (set by the run that took it). Index on `job` where `claimed_at` is null. The app inserts the row and NOTIFYs `ps_job_request` in the same transaction.
- `job_runs`: one row per run. `job`, `trigger` (schedule, request, chain, cli), `requested_by` uuid[] (the requests claimed), `started_at`, `finished_at`, `status` (running, ok, degraded, failed, skipped, abandoned), `counts` jsonb, `errors` jsonb (a list of redacted strings), `build_version`, `visual_target` (`"<server> / <database>"`), `host`. Index on (`job`, `id DESC`).
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds `ad-reconcile`, `session-refresh`, `integrity`, `backup-check`, `notify-send` and `notify-digest`.
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds `ad-reconcile`, `session-refresh`, `integrity`, `backup-check`, `notify-send` and `notify-digest`; 0012 adds `archive-copy`.
- `worker_heartbeat`: a single row (`id` = 1 by CHECK). `at`, `build`, `host`, `pid`, `running` text[] (the jobs running now). Upserted every 30 seconds.
## Notifications (phases 4 and 9)
@@ -389,6 +389,16 @@ Migration 0011 serves changes C7 and C8 in [PLAN.md](PLAN.md). The others need n
- `packets.vclosed_at` and `vclosed_by` (no migration; Gitea issue 31, change C5): on a VISUAL packet the feed is now the one writer. It sets `vclosed_at` (with `vclosed_by` null) when every leg is closed or cancelled and clears both when a leg shows open again. On a hand packet Clerical still sets them. See [FEED.md](FEED.md), "The apply".
- `qc_inspections.ncr` (no migration; Gitea issue 24, change C2) is now also written by the accept that carries the NCR number of an earlier reject.
## Archive location (phase 10, migration 0012)
Gitea issue 7. Filed packets are copied to a folder on the archive share (see [FEED.md](FEED.md), "archive-copy").
- `archive_copies` (new): one row per filed seal to copy. `seal_id` (unique, references `packet_seals`), `packet_id`, `status` (pending, done or failed by CHECK), `attempts` (tries so far, the successful one included), `last_error` (the last failure, for the Admin), `next_try_at` (when a failed row is due again), `folder` (the copy's folder relative to `ARCHIVE_ROOT`, for example `packets/L51234/L51234001/L51234-Lot001_D-5001_Manufacturing_Data_Book-seal7`), `manifest_sha256` (sha256 of the `manifest.json` written there), `created_at`, `done_at`. A done row must have `folder` and `done_at` (CHECK). Indexed on (`next_try_at`, `id`) where not done, and on `packet_id`. `ps_app` has SELECT, INSERT and UPDATE; DELETE and TRUNCATE are revoked, so a copy record is never removed. The worker queues a row for every unbroken seal that has a data book and no row yet, so turning the archive on later also copies earlier filings. A refile after a reopen is a new seal and a new row.
- `job_schedule` gains the `archive-copy` row.
- Settings row `files` (no migration): `{"archive_dir": "<folder relative to ARCHIVE_ROOT>"}`, saved by the Admin on File locations with compare and set on `version`. No row, or no `archive_dir`, means no folder is saved and nothing is copied.
- Audit actions: `admin.file_locations_updated` (detail `archive_dir: {from, to}`), `archive.checked` (every check: `source` admin, save, doctor or job, with `archive_dir`, `ok`, `resolved`, `free_bytes`, `message`; the job records its own check only when the result changes), `archive.copied` (`seal_id`, `folder`, `files`, `manifest_sha256`), `archive.copy_failed` (`seal_id`, `attempts`, `error`), `admin.archive_retry` (the "Retry now" job request).
- Notification event type `archive_failed` (seeded by the code like the others; condition, subject `archive`).
## VISUAL and drawing caches (phase 3)
Migration 0004. Every ID part is text (`wo_base`, `lot`, `split`, `sub`, part IDs, order IDs), with `''` in place of null in a key. `seq` and `piece` are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. [FEED.md](FEED.md) says how each is written.