Skip to content

feat(printer-models): Job lifecycle FSM with explicit state machine#49

Merged
strausmann merged 3 commits into
mainfrom
feat/job-lifecycle
May 11, 2026
Merged

feat(printer-models): Job lifecycle FSM with explicit state machine#49
strausmann merged 3 commits into
mainfrom
feat/job-lifecycle

Conversation

@strausmann
Copy link
Copy Markdown
Owner

Summary

PR A of split-Task 2.8. Adds the per-print-job state machine in isolation. The PrintQueue worker (PR B) imports Job, JobState, JobStateMachine, and InvalidStateTransitionError and drives transitions from its async loop.

What's in this PR

backend/app/services/job_lifecycle.py

  • JobState(StrEnum): QUEUED, PAUSED, PRINTING, COMPLETED, FAILED, CANCELLED.
  • _VALID_TRANSITIONS: dict[JobState, frozenset[JobState]] enumerates every legal edge. PRINTING is one-way to COMPLETED/FAILED per the Brother Raster Spec — the printer ignores commands once the raster stream starts, so the FSM mirrors that constraint.
  • _TERMINAL_STATES: frozenset[JobState] extracted to keep the side-effect logic DRY.
  • @dataclass Job with 12 fields. image_payload is repr=False (a real raster image is ~30 kB; default repr would shred log lines). _done_event is init=False, repr=False — internal plumbing, not for caller construction.
  • InvalidStateTransitionError(Exception) — message names both states for log self-containment.
  • JobStateMachine.transition(job, new_state) — stateless @staticmethod gatekeeper. Sets started_at on PRINTING, sets finished_at and _done_event on any terminal state.

backend/tests/unit/services/test_job_lifecycle.py — 13 tests

  • 8 happy / sad transition cases (queued→printing, paused→queued, cancel-from-paused, …)
  • 2 event-signal cases (event SET on COMPLETED, NOT set on PAUSED)
  • 2 terminal-event-parity cases (event also SET on FAILED and CANCELLED) — PR B will call _done_event.set() on FAILED, this locks in the invariant
  • 1 terminal-absorbing-invariant case (parameter-loops FAILED + CANCELLED × every other state target, all must raise)

What's NOT in this PR

  • No PrintQueue class, no PrinterWorkerState, no asyncio.Queue, no worker code — those land in PR B on top of this branch's symbols.
  • No persistence (Phase 5).

Test plan

  • pytest -q — 63/63 (50 prior + 13 new).
  • ruff format --check . clean.
  • ruff check . clean.
  • mypy app/ (strict) clean.
  • Job(id='x', printer_id='y') repr does not leak image_payload or _done_event.
  • Job(..., _done_event=other) raises TypeError (init guard works).

Review history (subagent-driven)

  1. Implementer b0e4dab — initial commit.
  2. Spec compliance: ✅ — matches the plan; documented deviation InvalidStateTransitionInvalidStateTransitionError to satisfy ruff N818.
  3. Code quality: APPROVED_WITH_NITS — flagged repr noise on image_payload, init-leak of _done_event, missing terminal-event coverage.
  4. Fix commit a44708b — repr/init suppression + 3 terminal-coverage tests.

Linked plan

docs/superpowers/plans/2026-05-11-label-printer-hub.md Task 2.8 (Phase 2 — split A of 2). PR B (PrintQueue worker) follows on top.

strausmann and others added 2 commits May 11, 2026 08:34
…nd terminal-event signalling

Implements JobState (StrEnum), Job (dataclass), JobStateMachine, and
InvalidStateTransitionError covering all valid transitions per the Brother
raster spec (no mid-print cancel/pause). Terminal states signal asyncio.Event
_done_event for the upcoming wait_for_job() in PR B (PrintQueue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…terminal-state coverage

- image_payload: field(repr=False) — avoids spamming logs with raw bytes
- _done_event: field(init=False, repr=False) — makes it truly internal;
  Job(..., _done_event=x) now raises TypeError instead of silently bypassing
  intended construction
- Add three tests: FAILED and CANCELLED set _done_event + finished_at,
  and both terminal states absorb all outgoing transitions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 11, 2026 08:41
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes the core state management logic for print jobs in isolation. By implementing a strict FSM, it ensures that job transitions adhere to hardware constraints, specifically preventing invalid mid-print operations. This foundational work provides the necessary structures for the upcoming print queue worker implementation.

Highlights

  • Job Lifecycle FSM: Introduced a formal Finite State Machine (FSM) for print jobs, defining valid transitions between states like QUEUED, PRINTING, and COMPLETED to ensure consistency.
  • Job Dataclass: Implemented a robust Job dataclass with internal plumbing for state management, including asyncio.Event integration for terminal state signaling.
  • Error Handling: Added a custom InvalidStateTransitionError to provide clear, self-contained feedback when illegal state transitions are attempted.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a job lifecycle finite-state machine for the print queue, including a JobState enum, a Job dataclass, and a JobStateMachine to manage transitions and side effects. It also adds comprehensive unit tests for the state machine logic. Feedback focuses on replacing naive datetimes with timezone-aware UTC timestamps for consistency and addressing a type safety violation regarding the use of Any in the job options dictionary.

Comment thread backend/app/services/job_lifecycle.py Outdated

import asyncio
from dataclasses import dataclass, field
from datetime import datetime
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use timezone-aware datetimes (UTC) to avoid inconsistencies related to local system time and DST transitions. Since the project uses Python 3.12+, datetime.UTC is the recommended approach.

Suggested change
from datetime import datetime
from datetime import UTC, datetime

Comment thread backend/app/services/job_lifecycle.py Outdated
id: str
printer_id: str
state: JobState = JobState.QUEUED
submitted_at: datetime = field(default_factory=datetime.now)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using naive datetime.now() can lead to issues when comparing timestamps across different environments or during daylight saving changes. Use datetime.now(UTC) for consistency.

Suggested change
submitted_at: datetime = field(default_factory=datetime.now)
submitted_at: datetime = field(default_factory=lambda: datetime.now(UTC))

finished_at: datetime | None = None
image_payload: bytes | None = field(default=None, repr=False)
tape_mm: int | None = None
options: dict[str, Any] = field(default_factory=dict)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This introduces Any, which is flagged by the repository's strict type safety policy (Priority 7). Consider using a more specific type for printer options, such as dict[str, str | int | bool | float | None], or define a TypeAlias if the schema is known.

References
  1. Flag new Any introductions. mypy --strict on app/. (link)

Comment thread backend/app/services/job_lifecycle.py Outdated
job.state = new_state

if new_state == JobState.PRINTING and job.started_at is None:
job.started_at = datetime.now()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use timezone-aware UTC timestamps for started_at.

Suggested change
job.started_at = datetime.now()
job.started_at = datetime.now(UTC)

Comment thread backend/app/services/job_lifecycle.py Outdated
job.started_at = datetime.now()

if new_state in _TERMINAL_STATES:
job.finished_at = datetime.now()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use timezone-aware UTC timestamps for finished_at.

Suggested change
job.finished_at = datetime.now()
job.finished_at = datetime.now(UTC)

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an explicit finite-state machine for per-print-job lifecycle tracking in the backend, intended to be consumed by the upcoming PrintQueue worker (PR B) to enforce Brother-spec-compliant transitions and centralize timestamp/event side effects.

Changes:

  • Added JobState, Job, InvalidStateTransitionError, and JobStateMachine.transition() to model and enforce legal job state transitions.
  • Implemented terminal-state side effects (finished timestamp + done-event signaling) and printing-start timestamping.
  • Added a unit test suite covering valid/invalid transitions and done-event behavior for terminal vs non-terminal transitions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
backend/app/services/job_lifecycle.py Adds the Job model and FSM transition gatekeeper with timestamps and terminal completion signaling.
backend/tests/unit/services/test_job_lifecycle.py Adds unit tests for transition validity and _done_event signaling invariants.

finished_at: datetime | None = None
image_payload: bytes | None = field(default=None, repr=False)
tape_mm: int | None = None
options: dict[str, Any] = field(default_factory=dict)
Comment thread backend/app/services/job_lifecycle.py Outdated
Comment on lines +88 to +90
# asyncio.Event is allowed on a dataclass field because asyncio fields on
# Jobs are only awaited from within an event loop; constructing them at
# import time is OK since asyncio.Event does not require a running loop.
…t comment

- Import UTC from datetime; replace all datetime.now() calls with
  datetime.now(UTC) (submitted_at default_factory, started_at and
  finished_at in transition()) so timestamps are timezone-aware and
  safe across DST boundaries and SQLModel persistence (Phase 5).
- Rewrite the _done_event comment: it was wrong about "import time" —
  default_factory runs at instance creation, and since Python 3.10
  asyncio.Event does not pin to a loop at construction.
- Add rationale comment on options: dict[str, Any] so future reviewers
  don't re-flag the intentional Any.
- Add test_timestamps_are_utc_aware() verifying tzinfo is UTC on all
  three timestamp fields (14th test, 64 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@strausmann strausmann merged commit 1a8c40e into main May 11, 2026
9 checks passed
github-actions Bot pushed a commit that referenced this pull request May 12, 2026
## 0.3.0 (2026-05-12)

* feat(config): pydantic-settings module with env-driven runtime configuration (#45) ([878e9e0](878e9e0)), closes [#45](#45)
* feat(integrations): AppLookupService aggregator — Phase 3 complete (#53) ([222bef4](222bef4)), closes [#53](#53)
* feat(integrations): Grocy + Spoolman lookup clients with shared NotFoundError base (#52) ([b1c9c3c](b1c9c3c)), closes [#52](#52)
* feat(integrations): LabelData schema + Snipe-IT lookup client (#51) ([3bc180f](3bc180f)), closes [#51](#51)
* feat(label-renderer): Template schema + Pillow/qrcode renderer for 1-bit label bitmaps (#54) ([fb77028](fb77028)), closes [#54](#54)
* feat(printer-models): Brother PT-Series TapeRegistry with TZe and heat-shrink specs (#47) ([7526019](7526019)), closes [#47](#47)
* feat(printer-models): Job lifecycle FSM with explicit state machine (#49) ([1a8c40e](1a8c40e)), closes [#49](#49)
* feat(printer-models): PrinterModel Protocol + ModelRegistry for plugin discovery (#48) ([2ae0e09](2ae0e09)), closes [#48](#48)
* feat(printer-models): PrintQueue worker with pause/resume/cancel/retry (#50) ([dfdf6fe](dfdf6fe)), closes [#50](#50)

[skip ci]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants