Archive copy must succeed before a packet counts as filed (Gitea issue 57)
ci / frontend (pull_request) Successful in 1m0s
ci / server (pull_request) Successful in 13m45s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 5m49s

FILE checks the archive folder and copies the packet inside the filing
transaction. A failed check or copy rolls the filing back, so the packet
stays in its previous stage; the person filing gets 503 archive_failed,
and in its own transaction the server writes an archive.filing_blocked
audit row and raises the critical archive_failed condition to Admin for
that packet (who tried, folder, error). The next successful filing clears
it. The archive-copy job now only catches up filings made while the
archive was off. PLAN question 53, CHANGELOG, FEED and SECURITY updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-03 15:52:09 -07:00
co-authored by Claude Opus 5.5
parent 5f8165c833
commit 6adaf7bf25
11 changed files with 254 additions and 98 deletions
+115 -14
View File
@@ -6,9 +6,14 @@ A path that leaves the root is refused: empty, `..`, an absolute path elsewhere,
the root, or a folder that does not exist. The check writes a probe file, reads it back and removes it. Nothing here
creates folders or deletes anything in the archive.
FILE inserts a job request for `archive-copy` in its own transaction. The worker job (worker/jobs/archive_copy.py)
queues every unbroken seal with a data book that has no `archive_copies` row, then copies the pending and due failed
rows. A failed copy never blocks filing: it is retried with a backoff and raised to Admin as `archive_failed`.
FILE copies the packet into the archive inside its own transaction (Gitea issue 57, Spencer 2026-11-03): the copy
must succeed before the packet counts as filed. A failed folder check or copy rolls the whole filing back, so the
packet stays where it was and can be filed again. The failure is then recorded in its own transaction and raised to
Admin as the `archive_failed` condition with subject `filing:<packet id>`, cleared when that packet files.
The worker job (worker/jobs/archive_copy.py) copies the seals filed while the archive was off: it queues every
unbroken seal with a data book that has no `archive_copies` row, then copies the pending and due failed rows with a
backoff, and raises `archive_failed` (subject `archive`) to Admin.
Folder layout under the archive folder: `<project>/<unit>/<data book name>-seal<seal id>/` with the data book PDF,
the packet's files (drawing page files, packet documents, the Q.C. folder documents of its unit and project, MTRs)
@@ -45,6 +50,8 @@ JOB = "archive-copy"
JOB_CHANNEL = "ps_job_request"
EVENT = "archive_failed"
EVENT_SUBJECT = "archive"
FILING_SUBJECT = "filing:{packet_id}"
FILING_TITLE = "Filing blocked: archive copy failed"
PROBE_PREFIX = ".packetstation-probe-"
MANIFEST = "manifest.json"
DIR_MAX = 400
@@ -72,6 +79,16 @@ class CopyError(Exception):
"""One copy failed. The message goes into archive_copies.last_error and the audit row."""
class FilingCopyFailed(Exception):
"""FILE could not copy the packet into the archive. The filing rolls back; the message is for the person filing
and for Admin."""
def __init__(self, message: str, archive_dir: str | None):
super().__init__(message)
self.message = message
self.archive_dir = archive_dir
@dataclass(frozen=True)
class CheckResult:
ok: bool
@@ -320,20 +337,104 @@ def request_copy(conn: Any, user_id: Any = None, note: str | None = None) -> Non
conn.execute("SELECT ps_notify(%s, %s)", (JOB_CHANNEL, json.dumps({"job": JOB}, separators=(",", ":"))))
def on_filed(conn: Any, settings: Settings, user_id: Any, packet_id: int) -> bool:
"""FILE's hook: request a prompt copy when the archive is on and a folder is saved. Never raises; a failure
here only means the copy waits for the next scheduled run. Returns whether a request went in."""
if root_of(settings) is None:
return False
def copy_at_filing(conn: Any, settings: Settings, packet_id: int, seal_id: int) -> dict[str, Any] | None:
"""FILE's copy, in the filing transaction: check the folder, copy the seal, record the copy as done.
Returns {folder, files}, or None when the archive is off (ARCHIVE_ROOT unset or no folder saved).
Raises FilingCopyFailed, which rolls the filing back."""
root = root_of(settings)
if root is None:
return None
archive_dir = load(conn)[0]
if archive_dir is None:
return None
rel, result = check(root, archive_dir)
if not result.ok or rel is None:
raise FilingCopyFailed(f"The archive folder check failed: {result.message}", archive_dir)
base = root.joinpath(*rel.split("/"))
try:
folder, sha, files = copy_seal(conn, settings, base, rel, seal_id)
except CopyError as e:
raise FilingCopyFailed(str(e), archive_dir) from None
except Exception as e: # anything unexpected blocks the filing the same way
log.exception("archive copy of packet %s at filing failed", packet_id)
raise FilingCopyFailed(f"{type(e).__name__}: {e}", archive_dir) from None
with conn.transaction():
conn.execute(
"INSERT INTO archive_copies (seal_id, packet_id, status, attempts, folder, manifest_sha256, done_at)"
" VALUES (%s, %s, 'done', 1, %s, %s, now())",
(seal_id, packet_id, folder, sha),
)
audit.append(
conn,
action="archive.copied",
subject_type="seal",
subject_id=str(seal_id),
packet_id=int(packet_id),
detail={"seal_id": seal_id, "folder": folder, "files": files, "manifest_sha256": sha, "source": "filing"},
)
from ..notify import resolve_event
resolve_event(
conn, EVENT, subject=FILING_SUBJECT.format(packet_id=packet_id), body="The packet was filed and copied."
)
return {"folder": folder, "files": files}
def record_filing_failure(conn: Any, who: Any, packet_id: int, failure: FilingCopyFailed) -> None:
"""After the filing rolled back: one `archive.filing_blocked` audit row and the Admin alert, in their own
transaction. Never raises; the person filing gets the refusal either way."""
from ..notify import raise_event
try:
with conn.transaction():
if load(conn)[0] is None:
return False
request_copy(conn, user_id, f"filed packet {packet_id}")
return True
row = conn.execute(
"SELECT pr.number, u.lot, p.drawing, p.status, p.dept_code FROM packets p"
" JOIN units u ON u.id = p.unit_id JOIN projects pr ON pr.id = u.project_id WHERE p.id = %s",
(packet_id,),
).fetchone()
label = packet_label(*row[:3]) if row else f"packet {packet_id}"
user_id = getattr(who, "user_id", None)
name = conn.execute("SELECT display_name FROM users WHERE id = %s", (user_id,)).fetchone()
by = name[0] if name else None
status = str(row[3]) if row else None
dept = row[4] if row else None
detail = {
"packet_id": packet_id,
"packet_label": label,
"status": status,
"dept": dept,
"archive_dir": failure.archive_dir,
"error": failure.message,
"by": by,
}
audit.append(
conn,
action="archive.filing_blocked",
user_id=user_id,
session_id=getattr(who, "session_id", None),
subject_type="packet",
subject_id=str(packet_id),
packet_id=int(packet_id),
detail=detail,
ip=getattr(who, "ip", None),
)
where = f"{status or 'where it was'}{' in ' + dept if dept else ''}"
raise_event(
conn,
EVENT,
subject=FILING_SUBJECT.format(packet_id=packet_id),
title=f"{FILING_TITLE}: {label}",
body=(
f"{by or 'Someone'} tried to file {label}, but the packet could not be copied to the archive,"
f" so it was not filed. It stays {where} and can be filed again once the archive works.\n\n"
f"Archive folder: {failure.archive_dir or '(none)'}\nError: {failure.message}"
),
detail=detail,
severity="critical",
condition=True,
)
except Exception:
log.exception("archive copy request for packet %s not made; the scheduled run picks it up", packet_id)
return False
log.exception("the blocked filing of packet %s was not recorded", packet_id)
def reset_backoff(conn: Any) -> int: