Skip to content

feat: add CRM people sync cache and human audit event ingestion#34

Merged
michaelmwu merged 3 commits into
mainfrom
michaelmwu/audit-log-schema
Feb 21, 2026
Merged

feat: add CRM people sync cache and human audit event ingestion#34
michaelmwu merged 3 commits into
mainfrom
michaelmwu/audit-log-schema

Conversation

@michaelmwu
Copy link
Copy Markdown
Member

@michaelmwu michaelmwu commented Feb 21, 2026

Description

Adds best-effort Discord CRM audit logging via a new bot audit helper and worker /audit/events ingest path backed by shared audit persistence utilities and new people/audit_events migrations, introduces CRM people sync flows (scheduled full sync, manual /sync/people, and webhook /webhooks/espocrm/people-sync) with new worker jobs/processors, and updates related settings, compose/env/docs, namespace packaging, and focused tests across bot/worker/shared modules.

Related Issue

N/A.

How Has This Been Tested?

./scripts/test.sh (203 passed).

Summary by CodeRabbit

  • New Features

    • CRM sync: full and per-contact sync into a local people cache; endpoints to trigger syncs and ingest audit events
    • Discord audit logging: best-effort audit writes for Discord actions
  • Bug Fixes

    • Improved skill detection to recognize shorter tokens (e.g., "go")
  • Documentation

    • Expanded environment/config docs and audit workflow guidance; README reorganized
  • Tests

    • New unit and integration tests covering CRM sync, audit logging, and settings validation
  • Chores

    • Docker and runtime config reorganized and env defaults clarified

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 21, 2026

📝 Walkthrough

Walkthrough

Introduces audit logging for Discord CRM commands, a CRM→people sync pipeline with scheduler and jobs, DB migrations for people and audit_events, worker API endpoints for sync/audit/webhook, shared audit/people persistence utilities, settings updates, namespace-package removals, and tests.

Changes

Cohort / File(s) Summary
Audit infra (shared + bot)
packages/shared/src/five08/audit.py, apps/discord_bot/src/five08/discord_bot/utils/audit.py
New audit domain models, upsert/resolve helpers, insert_audit_event persistence, and DiscordAuditLogger for best-effort background POSTs to worker /audit/events.
Discord CRM Cog changes
apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Integrated audit_logger, added _audit_command helper, audit calls across CRM commands, and _download_and_send_resume now returns bool for success/failure.
Worker CRM sync & jobs
apps/worker/src/five08/worker/crm/people_sync.py, apps/worker/src/five08/worker/jobs.py
New EspoPeopleSyncClient and PeopleSyncProcessor for paginated contact fetch + upsert, and two new jobs: full sync and per-contact sync.
Worker API & routing
apps/worker/src/five08/worker/api.py, apps/worker/src/five08/worker/actors.py
Added endpoints POST /sync/people, POST /webhooks/espocrm/people-sync, POST /audit/events; batch enqueueing, idempotency buckets, Postgres health/connection handling, CRM sync scheduler, and handler registrations.
Database migrations & models
apps/worker/src/five08/worker/migrations/.../20260221_0200_create_people_and_audit_events.py, apps/worker/src/five08/worker/migrations/.../20260221_0100_create_jobs_table.py, apps/worker/src/five08/worker/models.py
New Alembic migration creating people and audit_events tables with triggers/indexes; AuditEventPayload model added.
Settings & config
.env.example, apps/discord_bot/src/five08/discord_bot/config.py, apps/worker/src/five08/worker/config.py, packages/shared/src/five08/settings.py
Added audit and CRM sync settings, webhook ingest settings, runtime_env, secret validation for non-local, and minor defaults reordering.
Namespace package removals
apps/discord_bot/src/five08/__init__.py, apps/worker/src/five08/__init__.py, packages/shared/src/five08/__init__.py
Removed pkgutil.extend_path bootstrap; converted to standard packages (namespace behavior removed).
Docker / compose / build comments
apps/discord_bot/Dockerfile, apps/worker/Dockerfile, apps/worker/pyproject.toml, docker-compose.yml
Top-of-file image-pin comments added; docker-compose.yml adjusted Postgres host binding, added minio-init dependencies, and explicit POSTGRES_* env vars for worker services.
Skills / processing tweaks
apps/worker/src/five08/worker/crm/skills_extractor.py, apps/worker/src/five08/worker/crm/processor.py
Heuristic regex widened to allow 2-letter tokens; casefold() used instead of lower() for Unicode-aware deduplication.
Tests & coverage
tests/unit/..., tests/integration/test_email_monitor.py
Added/updated tests for DiscordAuditLogger, worker API (sync/audit/webhook), people sync normalizers, AuditEventPayload, skills extractor, queue parsing, and email monitor polling adjustments.
Misc docs
AGENTS.md, DEVELOPMENT.md, README.md, .env.example
Documentation additions describing audit flow, new endpoints, persisted data, and reorganized env var sections.

Sequence Diagram(s)

sequenceDiagram
    participant DiscordBot as Discord Bot
    participant CRMCog as CRM Cog
    participant AuditLogger as DiscordAuditLogger
    participant WorkerAPI as Worker API
    participant Database as Postgres

    DiscordBot->>CRMCog: /search-members (interaction)
    CRMCog->>CRMCog: execute command logic
    CRMCog->>AuditLogger: log_command(action, result, metadata)
    AuditLogger->>AuditLogger: build payload & spawn background task
    AuditLogger-->>DiscordBot: return immediately (best-effort)

    par Async background
        AuditLogger->>WorkerAPI: POST /audit/events (payload)
        WorkerAPI->>Database: insert_audit_event(payload)
        Database-->>WorkerAPI: created event (with person_id)
        WorkerAPI-->>AuditLogger: 201 Created
    end
Loading
sequenceDiagram
    participant API as Worker API
    participant Scheduler as CRM Sync Scheduler
    participant Processor as PeopleSyncProcessor
    participant Client as EspoPeopleSyncClient
    participant EspoCRM as EspoCRM API
    participant Database as Postgres

    API->>Scheduler: start (if enabled)
    Scheduler->>Scheduler: periodic trigger
    Scheduler->>API: enqueue sync_people_from_crm_job
    API->>Processor: sync_all_contacts()

    loop per page
        Processor->>Client: list_contact_page(offset, page_size)
        Client->>EspoCRM: GET /api/v1/Contact?offset=X&limit=Y
        EspoCRM-->>Client: contact page
        Client-->>Processor: page
    end

    loop per contact
        Processor->>Processor: _to_person_record(raw)
        Processor->>Database: upsert_person(person_record)
        Database-->>Processor: person_id
    end

    Processor-->>API: sync result (synced/failed counts)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through code and left a trace,
Audits stored, and people in place.
Commands that whisper, logs that sing,
Syncs that hum and jobs that spring.
Tiny paws, big deploy-ready grace!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main changes: adding CRM people sync caching and human audit event ingestion as core features.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch michaelmwu/audit-log-schema

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (21)
tests/unit/test_shared_queue.py (1)

24-34: Consider splitting into two focused tests.

The function currently asserts two distinct behaviours: (1) a known status maps correctly ("queued"QUEUED) and (2) an unknown status falls back to FAILED and emits a warning. Mixing them under …_unknown_values makes it harder to identify which path broke on failure.

♻️ Proposed split
-def test_parse_status_handles_unknown_values() -> None:
-    """Unknown DB status should fallback to FAILED and emit a warning."""
-    assert _parse_status("queued") == JobStatus.QUEUED
-
-    with patch("five08.queue.logger.warning") as mock_warning:
-        result = _parse_status("unexpected-status")
-
-    assert result == JobStatus.FAILED
-    mock_warning.assert_called_once_with(
-        "Unknown job status from DB: %s", "unexpected-status"
-    )
+def test_parse_status_known_value() -> None:
+    """Known DB status strings should map to the corresponding JobStatus."""
+    assert _parse_status("queued") == JobStatus.QUEUED
+
+
+def test_parse_status_unknown_value_falls_back_to_failed_with_warning() -> None:
+    """Unknown DB status should fall back to FAILED and emit a warning."""
+    with patch("five08.queue.logger.warning") as mock_warning:
+        result = _parse_status("unexpected-status")
+
+    assert result == JobStatus.FAILED
+    mock_warning.assert_called_once_with(
+        "Unknown job status from DB: %s", "unexpected-status"
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_shared_queue.py` around lines 24 - 34, Split the current
test_parse_status_handles_unknown_values into two focused tests: one that
verifies _parse_status("queued") returns JobStatus.QUEUED, and a second that
verifies an unknown status (e.g. "unexpected-status") causes _parse_status to
return JobStatus.FAILED and emits a warning via five08.queue.logger.warning; in
the second test use patch("five08.queue.logger.warning") as mock_warning, call
result = _parse_status("unexpected-status"), assert result == JobStatus.FAILED,
and assert mock_warning.assert_called_once_with("Unknown job status from DB:
%s", "unexpected-status").
apps/worker/Dockerfile (1)

15-16: Consolidate groupadd/useradd/chown into one RUN to reduce image layers.

Two separate RUN instructions means an extra writable layer in the image, which also inflates the image size since the chown -R copies the inodes for everything installed by uv sync.

♻️ Proposed consolidation
-RUN groupadd -r appgroup && useradd -r -g appgroup -d /app -s /usr/sbin/nologin appuser
-RUN chown -R appuser:appgroup /app
+RUN groupadd -r appgroup && useradd -r -g appgroup -d /app -s /usr/sbin/nologin appuser \
+    && chown -R appuser:appgroup /app
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/Dockerfile` around lines 15 - 16, Consolidate the two Dockerfile
RUN steps into a single RUN so groupadd, useradd and chown happen in one layer:
perform groupadd -r appgroup, useradd -r -g appgroup -d /app -s
/usr/sbin/nologin appuser, and then chown -R appuser:appgroup /app all within
the same RUN to avoid creating an extra writable layer; update the Dockerfile
lines that reference groupadd, useradd, chown, appuser, appgroup and /app
accordingly.
apps/worker/src/five08/worker/models.py (1)

3-5: Optional: consolidate the two from typing import statements.

Literal and Any are both from typing; a single import is marginally cleaner.

♻️ Proposed cleanup
-from typing import Literal
 from typing import Any
+from typing import Any, Literal
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/five08/worker/models.py` around lines 3 - 5, Consolidate the
two typing imports in apps/worker/src/five08/worker/models.py by replacing the
separate "from typing import Literal" and "from typing import Any" with a single
import that includes both symbols (e.g., import Literal and Any together from
typing); update the import line that currently references Literal and Any so
there's only one from typing import statement.
tests/unit/test_skills_extractor.py (1)

6-12: Optional: exercise the public interface instead of the private method.

SkillsExtractor() without OPENAI_API_KEY leaves self.client = None, so extract_skills(...) delegates straight to _extract_skills_heuristic. Calling the public entry point would keep the test decoupled from the internal routing detail and still cover the regex fix.

♻️ Proposed refactor
-    result = extractor._extract_skills_heuristic("Built services in Go and Python")
+    result = extractor.extract_skills("Built services in Go and Python")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_skills_extractor.py` around lines 6 - 12, Change the test to
exercise the public API by calling SkillsExtractor.extract_skills(...) instead
of the private _extract_skills_heuristic; instantiate SkillsExtractor() (which
may set self.client=None when OPENAI_API_KEY is absent) and call
extractor.extract_skills("Built services in Go and Python"), then assert that
"go" and "python" are present in the returned object's .skills so the test stays
decoupled from internal routing and still verifies the two-letter token
handling.
apps/worker/src/five08/worker/migrations/versions/20260221_0200_create_people_and_audit_events.py (2)

58-58: Redundant index: idx_people_discord_user_id duplicates the implicit index from uq_people_discord_user_id.

The unique constraint on discord_user_id (line 58) already creates an implicit B-tree index in Postgres. The explicit idx_people_discord_user_id index (line 63) is unnecessary and doubles write overhead for that column.

♻️ Remove the redundant index
     op.create_index("idx_people_email", "people", ["email"])
     op.create_index("idx_people_email_508", "people", ["email_508"])
-    op.create_index("idx_people_discord_user_id", "people", ["discord_user_id"])

And in downgrade():

-    op.drop_index("idx_people_discord_user_id", table_name="people")
     op.drop_index("idx_people_email_508", table_name="people")
     op.drop_index("idx_people_email", table_name="people")

Also applies to: 63-63

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/worker/src/five08/worker/migrations/versions/20260221_0200_create_people_and_audit_events.py`
at line 58, Remove the redundant explicit index on discord_user_id: delete the
sa.Index("idx_people_discord_user_id", "discord_user_id") declaration so the
UniqueConstraint("discord_user_id", name="uq_people_discord_user_id") provides
the implicit index, and also remove the corresponding
op.drop_index("idx_people_discord_user_id") call from downgrade() to avoid
attempting to drop a non-existent/duplicate index; ensure only the
UniqueConstraint remains for discord_user_id across upgrade() and downgrade().

132-170: Consider a reusable trigger function instead of per-table copies.

Each table gets its own <table>_set_updated_at_fn() that has identical logic (NEW.updated_at = NOW()). The existing jobs migration does the same. A single shared function (e.g., set_updated_at_fn()) referenced by all triggers would reduce duplication as new tables are added. Not blocking, but worth considering as the schema grows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/worker/src/five08/worker/migrations/versions/20260221_0200_create_people_and_audit_events.py`
around lines 132 - 170, Replace the per-table identical trigger functions with a
single shared function and update triggers to call it: remove
people_set_updated_at_fn and audit_events_set_updated_at_fn and instead create
one set_updated_at_fn() that sets NEW.updated_at = NOW() and returns NEW, then
update people_set_updated_at_tr and audit_events_set_updated_at_tr (and any
similar triggers like the jobs trigger) to EXECUTE FUNCTION set_updated_at_fn();
this deduplicates the logic while preserving the existing trigger names and
behavior.
apps/discord_bot/src/five08/discord_bot/utils/audit.py (2)

57-58: Fire-and-forget task reference may be garbage-collected before completion.

asyncio.create_task() returns a task, but log_command doesn't hold a strong reference to it beyond the local variable. If no other reference exists, the task can be garbage-collected (and cancelled) before it completes. CPython's event loop holds a weak reference to tasks, so in practice this usually works, but it's documented as unreliable.

Consider storing tasks in a set on the instance:

♻️ Keep strong references to background tasks

Add a _pending_tasks set to the constructor:

     def __init__(
         self,
         *,
         base_url: str | None,
         shared_secret: str | None,
         timeout_seconds: float,
     ) -> None:
         self.base_url = (base_url or "").strip().rstrip("/")
         self.shared_secret = (shared_secret or "").strip()
         self.timeout_seconds = timeout_seconds
+        self._pending_tasks: set[asyncio.Task[None]] = set()

Then in log_command:

         task = asyncio.create_task(self._post_event(event_payload))
-        task.add_done_callback(self._on_task_done)
+        self._pending_tasks.add(task)
+        task.add_done_callback(self._pending_tasks.discard)
+        task.add_done_callback(self._on_task_done)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/discord_bot/src/five08/discord_bot/utils/audit.py` around lines 57 - 58,
The created background Task in log_command is only held in a local variable and
can be garbage-collected; to fix, add a strong-reference set (e.g.,
self._pending_tasks) in the class constructor, add each created task
(asyncio.create_task(self._post_event(...))) to self._pending_tasks, and ensure
_on_task_done removes the completed task from self._pending_tasks (and also
handles exceptions from task.result()). This guarantees the background task for
_post_event is retained until completion and cleaned up in the _on_task_done
callback.

118-129: Caller-supplied metadata can silently overwrite base fields like command and guild_id.

base_metadata.update(metadata) at line 129 means any key in the caller's metadata dict (e.g., "command", "guild_id") will overwrite the auto-populated base fields. If this is intentional for flexibility, a brief docstring note would help. If not, consider namespacing or merging in the opposite direction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/discord_bot/src/five08/discord_bot/utils/audit.py` around lines 118 -
129, base_metadata currently gets blindly updated with the caller-supplied
metadata via base_metadata.update(metadata), allowing callers to overwrite
important fields like "command" and "guild_id"; change the merge so base fields
win (e.g., merge into metadata instead: merged = {**metadata, **base_metadata}
or update base_metadata only for keys not present), or namespace caller data
under an "extra" or "caller_metadata" key before merging; update the code paths
that use base_metadata (and any docstring/comments for the enclosing function)
to reflect the chosen behavior and ensure keys like command, guild_id,
channel_id, interaction_id cannot be silently overwritten.
apps/discord_bot/src/five08/discord_bot/cogs/crm.py (1)

106-116: "cog" in locals() is fragile — consider restructuring the exception handler.

If the variable cog is renamed or the code is refactored, this check will silently skip auditing. Since cog is assigned on line 68 before the role check on line 76, the only way it can be missing in the except block is if get_cog itself raises, which is unlikely. A simpler approach:

♻️ Use a default variable instead of locals() introspection
     async def callback(self, interaction: discord.Interaction) -> None:
         """Handle resume download button click."""
+        cog = None
         try:
             # Get the CRM cog to access the API
             cog = interaction.client.get_cog("CRMCog")  # type: ignore[attr-defined]
             ...
         except Exception as e:
             logger.error(f"Unexpected error in resume button callback: {e}")
-            if "cog" in locals() and cog:
+            if cog:
                 cog._audit_command(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py` around lines 106 - 116,
The except block uses a fragile "cog" in locals() check; instead ensure a stable
cog variable by declaring and initializing cog = None in the outer scope (before
the try) or assign cog to a known default so the except block can reliably
reference it; update the exception handler to check "if cog:" (or "if cog is not
None:") and call cog._audit_command(interaction=interaction,
action="crm.resume_download_button", ...) so auditing always runs when cog was
set, without relying on locals() introspection.
tests/unit/test_discord_audit.py (1)

22-57: Tests cover the key best-effort behaviors — consider adding a happy-path test in a follow-up.

The two tests cover the critical cases: disabled logger is a no-op, and exceptions are caught as warnings. Missing coverage for a successful POST (asserting the correct URL, headers, and payload are sent) would help catch regressions in _send_event_sync and _build_payload.

Would you like me to draft additional test cases for the happy-path POST and HTTP error-status scenarios?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_discord_audit.py` around lines 22 - 57, Add a happy-path test
and an HTTP-error-status test for DiscordAuditLogger to verify _send_event_sync
actually POSTS the expected URL, headers, timeout and JSON payload and treats
non-2xx responses as warnings; specifically, create tests that instantiate
DiscordAuditLogger with base_url/shared_secret, call _build_payload to produce
the payload, patch requests.post (patching
"five08.discord_bot.utils.audit.requests.post") to return a mock response with
status_code=200 and assert mock_post.called_once and that it was called with the
constructed events URL (base_url + "/events"), the JSON payload, a timeout, and
headers (verify the presence of the shared_secret in headers), and in a separate
test patch requests.post to return status_code=500 (or raise) and patch
logger.warning to assert a warning was emitted; use the existing helper
_mock_interaction() and reference DiscordAuditLogger._build_payload and
DiscordAuditLogger._send_event_sync to locate the code under test.
tests/unit/test_worker_people_sync.py (1)

6-46: Test coverage is a good start; consider adding edge-case scenarios.

Current tests cover the happy path for Discord fields and email fallback. Additional test cases that would strengthen coverage:

  • Missing id field → returns None
  • cDiscordRoles as a list or dict (other shapes handled in _discord_roles)
  • Whitespace-only or empty string fields
  • discord_user_id extracted from explicit field vs. parsed from username
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_worker_people_sync.py` around lines 6 - 46, Add unit tests
for edge cases around PeopleSyncProcessor._to_person_record and its helpers
(e.g., _discord_roles): ensure when input lacks "id" the function returns None;
verify cDiscordRoles passed as a list and as a dict are handled (call
_to_person_record with cDiscordRoles as ["Member","Admin"] and as {"roles":
["Member"]}); add tests for whitespace-only or empty string fields
(cDiscordUsername, cDiscordRoles, cGithubUsername) to ensure they normalize to
None/empty; and add tests where discord_user_id is provided explicitly
(discord_user_id field present) versus only embedded in cDiscordUsername to
confirm both extraction paths work.
apps/worker/src/five08/worker/crm/people_sync.py (2)

211-231: Discord user ID fallback re-reads raw username — correct but worth a comment.

_discord_username strips the (ID: ...) suffix, so the discord_username parameter passed here is already cleaned. The fallback at line 224 intentionally re-reads the raw cDiscordUsername to recover the ID. The logic is correct, but a brief inline comment explaining why it re-reads the raw field would help future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/five08/worker/crm/people_sync.py` around lines 211 - 231, In
_discord_user_id, add a brief inline comment before the fallback that re-reads
raw_contact["cDiscordUsername"] explaining that discord_username is already
cleaned by _discord_username (it strips the "(ID: ...)" suffix) so we must
re-read the raw cDiscordUsername and run _DISCORD_ID_RE on it to recover the
original numeric ID; reference the function name _discord_user_id, the parameter
discord_username, helper _text_or_none, and the regex _DISCORD_ID_RE in the
comment so future readers understand why the raw field is used instead of the
cleaned discord_username.

18-23: Duplicate Espo API URL construction with EspoCRMClient in processor.py.

Lines 22–23 duplicate the URL-building pattern found in apps/worker/src/five08/worker/crm/processor.py (lines 18–20). Consider extracting a small factory or shared helper (e.g., get_espo_api() -> EspoAPI) to keep the base-URL construction in one place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/five08/worker/crm/people_sync.py` around lines 18 - 23, Espo
API URL construction is duplicated between EspoPeopleSyncClient.__init__ and
EspoCRMClient in processor.py; create a shared factory function (e.g.,
get_espo_api()) that builds api_url from settings.espo_base_url and returns an
EspoAPI instance using settings.espo_api_key, then replace the direct
construction in EspoPeopleSyncClient.__init__ (and in EspoCRMClient) to call
get_espo_api() so the base-URL trimming and "/api/v1" suffix logic is
centralized.
packages/shared/src/five08/audit.py (2)

199-257: normalize_actor_subject is called twice; consider refactoring resolve_person_id to accept a pre-normalized subject.

In insert_audit_event, resolve_person_id (line 209) internally calls normalize_actor_subject, and then it's called again at line 214. This is a minor redundancy. More importantly, the person lookup and the event insert happen in two separate connections/transactions — if the person row is deleted between the two calls, person_id will be stale. Given the "best-effort" nature of audit linking this is probably acceptable, but worth noting.

A small refactor could let resolve_person_id accept an already-normalized subject to avoid the double work, or both operations could share a single connection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/five08/audit.py` around lines 199 - 257, The
insert_audit_event function currently calls normalize_actor_subject twice (once
inside resolve_person_id and once directly) and performs person lookup and
insert in separate DB transactions; refactor resolve_person_id to accept an
optional pre-normalized subject (add parameter normalized_subject:
Optional[str]) and update its callers so insert_audit_event computes
normalized_subject once (via normalize_actor_subject(payload.actor_provider,
payload.actor_subject)) and passes that into resolve_person_id, and/or open a
single connection with get_postgres_connection and reuse it for both the
resolve_person_id lookup and the INSERT to ensure consistent person_id
resolution; update function signature and all call sites of resolve_person_id
accordingly (symbols: insert_audit_event, resolve_person_id,
normalize_actor_subject, get_postgres_connection).

108-160: Each call to upsert_person / resolve_person_id / insert_audit_event opens and closes a new DB connection.

get_postgres_connection creates a fresh connection every time. For the current audit-event write path (insert_audit_event), that means two connections are opened (one inside resolve_person_id, one for the INSERT). Under load — e.g., batch imports triggering many audit events — this can exhaust connections or add latency. Consider accepting an optional connection parameter or introducing a lightweight connection pool (e.g., psycopg_pool.ConnectionPool).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/five08/audit.py` around lines 108 - 160, The code opens a
fresh DB connection on every call to upsert_person / resolve_person_id /
insert_audit_event which causes excessive connections; modify these functions
(upsert_person, resolve_person_id, insert_audit_event) to accept an optional
connection/transaction parameter (e.g., conn) and, if provided, reuse it instead
of calling get_postgres_connection(); when conn is None fall back to creating a
new connection with get_postgres_connection() and ensure you only close
connections you created (do not close a passed-in conn). Alternatively,
introduce a shared psycopg_pool.ConnectionPool instance and have these functions
acquire/release connections from that pool instead of calling
get_postgres_connection() directly; update call sites to pass the same conn (or
use a pooled client) for the whole audit-event write path to avoid creating
multiple connections per request.
apps/worker/src/five08/worker/api.py (2)

337-380: Consider extracting shared webhook validation logic.

espocrm_people_sync_webhook_handler and espocrm_webhook_handler (Line 236) share identical auth → JSON parse → list check → EspoCRMWebhookPayload.from_list → dedup logic. Extracting this into a shared helper would reduce duplication and ensure both paths stay in sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/five08/worker/api.py` around lines 337 - 380, Extract the
repeated auth/JSON/list/validation/dedup logic from
espocrm_people_sync_webhook_handler and espocrm_webhook_handler into a single
helper (e.g. _parse_and_validate_espocrm_webhook(request)) that: calls
_is_authorized, attempts await request.json(), checks the payload is a list,
calls EspoCRMWebhookPayload.from_list(payload_data) and returns the deduplicated
event id list (or raises/returns appropriate web responses/errors the handlers
can forward). Replace the duplicated blocks in
espocrm_people_sync_webhook_handler and espocrm_webhook_handler to call this
helper and then continue with their existing enqueueing logic (such as creating
the bucket and invoking _enqueue_espocrm_people_sync_batch), preserving the same
response shapes and error status codes.

383-433: audit_event_handler performs a synchronous DB write in the request path, deviating from the ingest-fast guideline.

The coding guideline for this file states: "Keep ingest endpoints fast by validating input, persisting jobs, enqueueing, and returning 202 status." This handler instead blocks on insert_audit_event via asyncio.to_thread and returns 201. If the DB is slow, individual requests will stall.

If immediate event_id/person_id feedback is not required by callers, consider enqueueing the audit event as a job (matching the pattern of the other endpoints) and returning 202. If the synchronous write is intentional, documenting the rationale would help. As per coding guidelines, "Keep ingest endpoints fast by validating input, persisting jobs, enqueueing, and returning 202 status".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/five08/worker/api.py` around lines 383 - 433,
audit_event_handler currently blocks on a synchronous DB write via
asyncio.to_thread(insert_audit_event, ... ) and returns 201; change it to follow
the ingest-fast pattern: after validating and model-validating payload
(AuditEventPayload) build the AuditEventInput but do NOT call insert_audit_event
directly — instead persist/enqueue a job (e.g., create or enqueue an audit
ingest job using your existing job-queue helper used by other endpoints) using
settings and the AuditEventInput, then return a 202 response with the job
id/acknowledgement; if a synchronous write is truly required, add a clear
comment explaining why and keep the existing insert_audit_event call and 201
response.
tests/unit/test_worker_api.py (1)

240-247: Consider asserting insert_audit_event was called with the expected AuditEventInput.

The test verifies the response shape but doesn't assert that insert_audit_event received the correctly mapped AuditEventInput. This leaves the payload → domain-model mapping untested — a bug in the field mapping (e.g., swapped actor_subject/actor_display_name) would pass silently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_worker_api.py` around lines 240 - 247, Add an assertion that
insert_audit_event was invoked with the correctly mapped AuditEventInput to
validate payload→domain mapping: after calling audit_event_handler(request)
build the expected AuditEventInput instance (populating fields like
actor_subject, actor_display_name, person_id, event_type, metadata from the test
request) and assert mock_insert was called once with that AuditEventInput (use
mock_insert.assert_called_once_with(...) or equivalent); reference the
insert_audit_event mock and the AuditEventInput type to locate where to add this
check in the test that calls audit_event_handler.
tests/integration/test_email_monitor.py (3)

48-60: No explicit assertions — test only verifies the coroutine doesn't raise.

The test doesn't assert anything about observable side-effects (e.g. that channel.send was or was not called, or that the email was skipped gracefully). With the mock data (None, b"malformed email data") the tuple check isinstance(response_part, tuple) is True, so the parse path is reached and email.message_from_string silently succeeds. Adding at least a negative assertion (e.g. that no channel messages were sent for the malformed message) would make the intent verifiable.

♻️ Proposed addition
         with patch("imaplib.IMAP4_SSL", return_value=mock_imap_server):
             await EmailMonitor.task_poll_inbox.coro(email_monitor_real_poll)
+
+        # Malformed data should not trigger any channel messages
+        mock_discord_channel.send.assert_not_called()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_email_monitor.py` around lines 48 - 60, The test
test_poll_inbox_handles_email_parsing_errors currently only ensures the
coroutine doesn't raise but has no observable assertions; update it to assert
the expected side-effect for malformed data by verifying that the mocked Discord
channel did not receive any sends. After calling await
EmailMonitor.task_poll_inbox.coro(email_monitor_real_poll) (with IMAP4_SSL
patched and mock_imap_server.fetch returning the malformed tuple), add an
assertion like mock_discord_channel.send.assert_not_called() (or the appropriate
mock on email_monitor_real_poll.bot.get_channel().send) so the test explicitly
verifies that no message was posted for the malformed email.

29-32: Fixture is fragile against __init__ re-enabling task auto-start.

email_monitor_real_poll relies on task_poll_inbox.start() remaining commented out in EmailMonitor.__init__. If that line is ever uncommented, this fixture would kick off a real asyncio.Task against a mock bot and could cause test pollution or hangs.

♻️ Proposed guard
 `@pytest.fixture`
 def email_monitor_real_poll(self, mock_bot):
     """Create an EmailMonitor instance with the real poll coroutine."""
-    return EmailMonitor(mock_bot)
+    with patch.object(EmailMonitor, "__init__", lambda self, bot: setattr(self, "bot", bot)):
+        return EmailMonitor(mock_bot)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_email_monitor.py` around lines 29 - 32, The fixture
email_monitor_real_poll is fragile because it assumes EmailMonitor.__init__
won't call task_poll_inbox.start(); to fix, prevent any auto-start when creating
EmailMonitor by stubbing or monkeypatching the task starter before
instantiation: e.g., replace EmailMonitor.task_poll_inbox or its start method
with a no-op/dummy object (or set a flag on EmailMonitor to disable auto-start)
so that constructing EmailMonitor(mock_bot) cannot launch a real asyncio.Task;
update the fixture to perform this guard (referencing EmailMonitor and
task_poll_inbox.start) before calling EmailMonitor(mock_bot).

44-46: Use the documented Loop.__call__ API instead of the internal .coro attribute.

.coro is not part of discord.py's documented public API for discord.ext.tasks.Loop and could break silently on version updates. The documented alternative is to call the bound loop directly via __call__:

await email_monitor_real_poll.task_poll_inbox()

This uses the official API that has been stable since discord.py v1.6.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_email_monitor.py` around lines 44 - 46, Replace the
use of the internal .coro attribute on the Loop with the documented
Loop.__call__ API: instead of calling
EmailMonitor.task_poll_inbox.coro(email_monitor_real_poll), invoke the bound
loop directly via email_monitor_real_poll.task_poll_inbox() (i.e., await the
loop call), keeping the same exception expectation; reference symbols:
EmailMonitor.task_poll_inbox, email_monitor_real_poll and Loop.__call__.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Around line 65-66: AUDIT_API_BASE_URL is empty and lacks the guidance comments
used for other required envs; update the .env.example to set a sensible local
default (e.g., AUDIT_API_BASE_URL=http://worker:8090) and add a short comment
like "Required: URL of the Audit API (local dev default shown)" similar to the
existing comments for WEBHOOK_SHARED_SECRET and OPENAI_API_KEY so developers
know what to set and local-compose works out of the box; leave
AUDIT_API_TIMEOUT_SECONDS unchanged unless you want to document its units in a
one-line comment.

In `@apps/discord_bot/Dockerfile`:
- Around line 1-2: Update the Dockerfile comment to state the exact uv image
version that corresponds to the pinned digest: replace the current tag reference
"ghcr.io/astral-sh/uv:python3.12-bookworm-slim" with the fully qualified tag
including the uv release (e.g.,
"ghcr.io/astral-sh/uv:0.10.2-python3.12-bookworm-slim") so the comment next to
the FROM line accurately documents which uv version the sha256 digest
represents.

In `@apps/worker/Dockerfile`:
- Around line 1-2: Update the base image comment and FROM line so the Dockerfile
no longer references the removed bookworm-slim tag: replace the comment on the
first line and the existing FROM ghcr.io/astral-sh/uv@sha256:e5b65587... with a
pinned python3.12-trixie image digest (e.g., FROM
ghcr.io/astral-sh/uv@sha256:<new-digest>), obtaining the correct SHA256 by
querying GHCR for the python3.12-trixie manifest; ensure the comment reflects
the uv 0.10.0 removal date and that the new digest is pinned for reproducible
builds.

In `@apps/worker/src/five08/worker/api.py`:
- Around line 403-424: The try/except around the insert_audit_event call only
catches ValueError, leaving DB errors unhandled; update the error handling in
the block that calls insert_audit_event so that after the existing ValueError
except you also catch database-related exceptions (preferably the project's DB
exception class such as SQLAlchemyError/DatabaseError or, if uncertain, a
generic Exception as a fallback) and return a sanitized 500 JSON response while
logging the exception; keep the AuditEventInput construction and ValueError
handling intact and ensure you reference insert_audit_event and AuditEventInput
when locating the code to modify.
- Around line 104-108: In _log_background_task_result replace the
logger.exception call (which runs outside an except block) with a logger.error
call that passes exc_info=exc so the task's actual traceback is logged; locate
the exception retrieval via task.exception() and change the logging invocation
to use logger.error("Background task failed name=%s error=%s", name, exc,
exc_info=exc) so the traceback is preserved.
- Around line 258-264: The created background task for _enqueue_espocrm_batch is
only weakly referenced and may be garbage-collected; instead register tasks in
the app-level background set used elsewhere (e.g., CRM_SYNC_TASK_KEY) so they
are strongly referenced: obtain or create the set on request.app, add the
created task to that set, attach a done callback that both
_log_background_task_result and removes the task from the set when complete;
apply the same change to espocrm_people_sync_webhook_handler and ensure the
app-level set is initialized on startup (as other code does) so tasks persist
until finished.

In
`@apps/worker/src/five08/worker/migrations/versions/20260221_0200_create_people_and_audit_events.py`:
- Around line 17-21: The migration creates UUID ids for people and audit_events
without server-side defaults while jobs uses
server_default=sa.text("gen_random_uuid()"), which is intentional because the
application (packages/shared/src/five08/audit.py) generates UUIDs via uuid4()
for person_id and event_id; update the people/audit_events migration (look for
op.create_table with "people" and the audit_events op.create_table) to include a
short explanatory comment above the table/column definitions stating that UUIDs
are generated by the application (uuid4) and therefore no server_default is set,
or alternatively standardize by adding
server_default=sa.text("gen_random_uuid()") to people.id and audit_events.id if
you prefer DB-side generation—choose one approach and document it in the
migration so the inconsistency with the jobs table is explicit.

In `@apps/worker/src/five08/worker/models.py`:
- Line 73: The occurred_at field currently allows naive datetimes; change its
type to pydantic.AwareDatetime to enforce timezone-aware values, update the
import to pull AwareDatetime from pydantic, and keep the default None if you
still want optionality (e.g., occurred_at: AwareDatetime | None = None) so
validation will reject tz-naive datetimes at assignment/parse time; locate the
occurred_at definition in the model class and replace the datetime type with
AwareDatetime and add the import.

In `@docker-compose.yml`:
- Around line 83-86: The compose file exposes POSTGRES_URL without a fallback
which causes runtime errors where the app expects it (see code that raises
RuntimeError in migrations and settings validation requiring POSTGRES_URL);
update the docker-compose environment entry for POSTGRES_URL to provide a
sensible default fallback (e.g., a local Postgres connection URI that matches
POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB) so that ${POSTGRES_URL} becomes
${POSTGRES_URL:-<local-postgres-connection-string>} and migrations/non-local
deployments won't fail on fresh setups.

In `@packages/shared/src/five08/settings.py`:
- Around line 44-57: The validator validate_required_secrets currently calls
os.getenv(...) which bypasses pydantic-settings’ .env loading and can
false-positive; change it to check the resolved model attributes
(self.postgres_url and self.minio_root_password) instead of os.getenv, keeping
the runtime_env logic and raising the same ValueError messages if those self
fields are empty/None; if you want to enforce “explicit” (non-default) values
compare against known defaults rather than os.getenv, and remove the unused os
import if no longer referenced.

In `@tests/integration/test_email_monitor.py`:
- Around line 35-38: The test docstring for test_poll_inbox_handles_imap_errors
is incorrect: it says IMAP errors are "handled gracefully" but the test asserts
the error propagates using pytest.raises; update the docstring in
tests/integration/test_email_monitor.py for the async
test_poll_inbox_handles_imap_errors to state that IMAP errors are
propagated/bubbled up (e.g., "Test that IMAP errors are propagated") so it
accurately reflects the assertion and the inline comment about bubble-up
behavior.

---

Duplicate comments:
In `@docker-compose.yml`:
- Around line 118-121: The POSTGRES_URL environment variable in
docker-compose.yml has no default fallback while
POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB do; update the POSTGRES_URL entry to
provide a sensible default fallback (e.g., matching the other defaults and
service host/port) using the same shell-style default expansion
(${POSTGRES_URL:-...}) so the stack can run without an explicit env set, and
ensure the default URL references the same DB name/user/password used by
POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB.

---

Nitpick comments:
In `@apps/discord_bot/src/five08/discord_bot/cogs/crm.py`:
- Around line 106-116: The except block uses a fragile "cog" in locals() check;
instead ensure a stable cog variable by declaring and initializing cog = None in
the outer scope (before the try) or assign cog to a known default so the except
block can reliably reference it; update the exception handler to check "if cog:"
(or "if cog is not None:") and call cog._audit_command(interaction=interaction,
action="crm.resume_download_button", ...) so auditing always runs when cog was
set, without relying on locals() introspection.

In `@apps/discord_bot/src/five08/discord_bot/utils/audit.py`:
- Around line 57-58: The created background Task in log_command is only held in
a local variable and can be garbage-collected; to fix, add a strong-reference
set (e.g., self._pending_tasks) in the class constructor, add each created task
(asyncio.create_task(self._post_event(...))) to self._pending_tasks, and ensure
_on_task_done removes the completed task from self._pending_tasks (and also
handles exceptions from task.result()). This guarantees the background task for
_post_event is retained until completion and cleaned up in the _on_task_done
callback.
- Around line 118-129: base_metadata currently gets blindly updated with the
caller-supplied metadata via base_metadata.update(metadata), allowing callers to
overwrite important fields like "command" and "guild_id"; change the merge so
base fields win (e.g., merge into metadata instead: merged = {**metadata,
**base_metadata} or update base_metadata only for keys not present), or
namespace caller data under an "extra" or "caller_metadata" key before merging;
update the code paths that use base_metadata (and any docstring/comments for the
enclosing function) to reflect the chosen behavior and ensure keys like command,
guild_id, channel_id, interaction_id cannot be silently overwritten.

In `@apps/worker/Dockerfile`:
- Around line 15-16: Consolidate the two Dockerfile RUN steps into a single RUN
so groupadd, useradd and chown happen in one layer: perform groupadd -r
appgroup, useradd -r -g appgroup -d /app -s /usr/sbin/nologin appuser, and then
chown -R appuser:appgroup /app all within the same RUN to avoid creating an
extra writable layer; update the Dockerfile lines that reference groupadd,
useradd, chown, appuser, appgroup and /app accordingly.

In `@apps/worker/src/five08/worker/api.py`:
- Around line 337-380: Extract the repeated auth/JSON/list/validation/dedup
logic from espocrm_people_sync_webhook_handler and espocrm_webhook_handler into
a single helper (e.g. _parse_and_validate_espocrm_webhook(request)) that: calls
_is_authorized, attempts await request.json(), checks the payload is a list,
calls EspoCRMWebhookPayload.from_list(payload_data) and returns the deduplicated
event id list (or raises/returns appropriate web responses/errors the handlers
can forward). Replace the duplicated blocks in
espocrm_people_sync_webhook_handler and espocrm_webhook_handler to call this
helper and then continue with their existing enqueueing logic (such as creating
the bucket and invoking _enqueue_espocrm_people_sync_batch), preserving the same
response shapes and error status codes.
- Around line 383-433: audit_event_handler currently blocks on a synchronous DB
write via asyncio.to_thread(insert_audit_event, ... ) and returns 201; change it
to follow the ingest-fast pattern: after validating and model-validating payload
(AuditEventPayload) build the AuditEventInput but do NOT call insert_audit_event
directly — instead persist/enqueue a job (e.g., create or enqueue an audit
ingest job using your existing job-queue helper used by other endpoints) using
settings and the AuditEventInput, then return a 202 response with the job
id/acknowledgement; if a synchronous write is truly required, add a clear
comment explaining why and keep the existing insert_audit_event call and 201
response.

In `@apps/worker/src/five08/worker/crm/people_sync.py`:
- Around line 211-231: In _discord_user_id, add a brief inline comment before
the fallback that re-reads raw_contact["cDiscordUsername"] explaining that
discord_username is already cleaned by _discord_username (it strips the "(ID:
...)" suffix) so we must re-read the raw cDiscordUsername and run _DISCORD_ID_RE
on it to recover the original numeric ID; reference the function name
_discord_user_id, the parameter discord_username, helper _text_or_none, and the
regex _DISCORD_ID_RE in the comment so future readers understand why the raw
field is used instead of the cleaned discord_username.
- Around line 18-23: Espo API URL construction is duplicated between
EspoPeopleSyncClient.__init__ and EspoCRMClient in processor.py; create a shared
factory function (e.g., get_espo_api()) that builds api_url from
settings.espo_base_url and returns an EspoAPI instance using
settings.espo_api_key, then replace the direct construction in
EspoPeopleSyncClient.__init__ (and in EspoCRMClient) to call get_espo_api() so
the base-URL trimming and "/api/v1" suffix logic is centralized.

In
`@apps/worker/src/five08/worker/migrations/versions/20260221_0200_create_people_and_audit_events.py`:
- Line 58: Remove the redundant explicit index on discord_user_id: delete the
sa.Index("idx_people_discord_user_id", "discord_user_id") declaration so the
UniqueConstraint("discord_user_id", name="uq_people_discord_user_id") provides
the implicit index, and also remove the corresponding
op.drop_index("idx_people_discord_user_id") call from downgrade() to avoid
attempting to drop a non-existent/duplicate index; ensure only the
UniqueConstraint remains for discord_user_id across upgrade() and downgrade().
- Around line 132-170: Replace the per-table identical trigger functions with a
single shared function and update triggers to call it: remove
people_set_updated_at_fn and audit_events_set_updated_at_fn and instead create
one set_updated_at_fn() that sets NEW.updated_at = NOW() and returns NEW, then
update people_set_updated_at_tr and audit_events_set_updated_at_tr (and any
similar triggers like the jobs trigger) to EXECUTE FUNCTION set_updated_at_fn();
this deduplicates the logic while preserving the existing trigger names and
behavior.

In `@apps/worker/src/five08/worker/models.py`:
- Around line 3-5: Consolidate the two typing imports in
apps/worker/src/five08/worker/models.py by replacing the separate "from typing
import Literal" and "from typing import Any" with a single import that includes
both symbols (e.g., import Literal and Any together from typing); update the
import line that currently references Literal and Any so there's only one from
typing import statement.

In `@packages/shared/src/five08/audit.py`:
- Around line 199-257: The insert_audit_event function currently calls
normalize_actor_subject twice (once inside resolve_person_id and once directly)
and performs person lookup and insert in separate DB transactions; refactor
resolve_person_id to accept an optional pre-normalized subject (add parameter
normalized_subject: Optional[str]) and update its callers so insert_audit_event
computes normalized_subject once (via
normalize_actor_subject(payload.actor_provider, payload.actor_subject)) and
passes that into resolve_person_id, and/or open a single connection with
get_postgres_connection and reuse it for both the resolve_person_id lookup and
the INSERT to ensure consistent person_id resolution; update function signature
and all call sites of resolve_person_id accordingly (symbols:
insert_audit_event, resolve_person_id, normalize_actor_subject,
get_postgres_connection).
- Around line 108-160: The code opens a fresh DB connection on every call to
upsert_person / resolve_person_id / insert_audit_event which causes excessive
connections; modify these functions (upsert_person, resolve_person_id,
insert_audit_event) to accept an optional connection/transaction parameter
(e.g., conn) and, if provided, reuse it instead of calling
get_postgres_connection(); when conn is None fall back to creating a new
connection with get_postgres_connection() and ensure you only close connections
you created (do not close a passed-in conn). Alternatively, introduce a shared
psycopg_pool.ConnectionPool instance and have these functions acquire/release
connections from that pool instead of calling get_postgres_connection()
directly; update call sites to pass the same conn (or use a pooled client) for
the whole audit-event write path to avoid creating multiple connections per
request.

In `@tests/integration/test_email_monitor.py`:
- Around line 48-60: The test test_poll_inbox_handles_email_parsing_errors
currently only ensures the coroutine doesn't raise but has no observable
assertions; update it to assert the expected side-effect for malformed data by
verifying that the mocked Discord channel did not receive any sends. After
calling await EmailMonitor.task_poll_inbox.coro(email_monitor_real_poll) (with
IMAP4_SSL patched and mock_imap_server.fetch returning the malformed tuple), add
an assertion like mock_discord_channel.send.assert_not_called() (or the
appropriate mock on email_monitor_real_poll.bot.get_channel().send) so the test
explicitly verifies that no message was posted for the malformed email.
- Around line 29-32: The fixture email_monitor_real_poll is fragile because it
assumes EmailMonitor.__init__ won't call task_poll_inbox.start(); to fix,
prevent any auto-start when creating EmailMonitor by stubbing or monkeypatching
the task starter before instantiation: e.g., replace
EmailMonitor.task_poll_inbox or its start method with a no-op/dummy object (or
set a flag on EmailMonitor to disable auto-start) so that constructing
EmailMonitor(mock_bot) cannot launch a real asyncio.Task; update the fixture to
perform this guard (referencing EmailMonitor and task_poll_inbox.start) before
calling EmailMonitor(mock_bot).
- Around line 44-46: Replace the use of the internal .coro attribute on the Loop
with the documented Loop.__call__ API: instead of calling
EmailMonitor.task_poll_inbox.coro(email_monitor_real_poll), invoke the bound
loop directly via email_monitor_real_poll.task_poll_inbox() (i.e., await the
loop call), keeping the same exception expectation; reference symbols:
EmailMonitor.task_poll_inbox, email_monitor_real_poll and Loop.__call__.

In `@tests/unit/test_discord_audit.py`:
- Around line 22-57: Add a happy-path test and an HTTP-error-status test for
DiscordAuditLogger to verify _send_event_sync actually POSTS the expected URL,
headers, timeout and JSON payload and treats non-2xx responses as warnings;
specifically, create tests that instantiate DiscordAuditLogger with
base_url/shared_secret, call _build_payload to produce the payload, patch
requests.post (patching "five08.discord_bot.utils.audit.requests.post") to
return a mock response with status_code=200 and assert mock_post.called_once and
that it was called with the constructed events URL (base_url + "/events"), the
JSON payload, a timeout, and headers (verify the presence of the shared_secret
in headers), and in a separate test patch requests.post to return
status_code=500 (or raise) and patch logger.warning to assert a warning was
emitted; use the existing helper _mock_interaction() and reference
DiscordAuditLogger._build_payload and DiscordAuditLogger._send_event_sync to
locate the code under test.

In `@tests/unit/test_shared_queue.py`:
- Around line 24-34: Split the current test_parse_status_handles_unknown_values
into two focused tests: one that verifies _parse_status("queued") returns
JobStatus.QUEUED, and a second that verifies an unknown status (e.g.
"unexpected-status") causes _parse_status to return JobStatus.FAILED and emits a
warning via five08.queue.logger.warning; in the second test use
patch("five08.queue.logger.warning") as mock_warning, call result =
_parse_status("unexpected-status"), assert result == JobStatus.FAILED, and
assert mock_warning.assert_called_once_with("Unknown job status from DB: %s",
"unexpected-status").

In `@tests/unit/test_skills_extractor.py`:
- Around line 6-12: Change the test to exercise the public API by calling
SkillsExtractor.extract_skills(...) instead of the private
_extract_skills_heuristic; instantiate SkillsExtractor() (which may set
self.client=None when OPENAI_API_KEY is absent) and call
extractor.extract_skills("Built services in Go and Python"), then assert that
"go" and "python" are present in the returned object's .skills so the test stays
decoupled from internal routing and still verifies the two-letter token
handling.

In `@tests/unit/test_worker_api.py`:
- Around line 240-247: Add an assertion that insert_audit_event was invoked with
the correctly mapped AuditEventInput to validate payload→domain mapping: after
calling audit_event_handler(request) build the expected AuditEventInput instance
(populating fields like actor_subject, actor_display_name, person_id,
event_type, metadata from the test request) and assert mock_insert was called
once with that AuditEventInput (use mock_insert.assert_called_once_with(...) or
equivalent); reference the insert_audit_event mock and the AuditEventInput type
to locate where to add this check in the test that calls audit_event_handler.

In `@tests/unit/test_worker_people_sync.py`:
- Around line 6-46: Add unit tests for edge cases around
PeopleSyncProcessor._to_person_record and its helpers (e.g., _discord_roles):
ensure when input lacks "id" the function returns None; verify cDiscordRoles
passed as a list and as a dict are handled (call _to_person_record with
cDiscordRoles as ["Member","Admin"] and as {"roles": ["Member"]}); add tests for
whitespace-only or empty string fields (cDiscordUsername, cDiscordRoles,
cGithubUsername) to ensure they normalize to None/empty; and add tests where
discord_user_id is provided explicitly (discord_user_id field present) versus
only embedded in cDiscordUsername to confirm both extraction paths work.

Comment thread .env.example
Comment on lines +65 to +66
AUDIT_API_BASE_URL=
AUDIT_API_TIMEOUT_SECONDS=2.0
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

AUDIT_API_BASE_URL is blank with no guidance comment.

Other empty required values (e.g., WEBHOOK_SHARED_SECRET, OPENAI_API_KEY) have comments like "Required: …" or descriptive placeholders. Consider adding a comment and a sensible local default (e.g., http://worker:8090) so developers know what to set and local-compose works out of the box.

Proposed fix
-AUDIT_API_BASE_URL=
-AUDIT_API_TIMEOUT_SECONDS=2.0
+# Required for Discord bot audit logging; points to the worker ingest API
+AUDIT_API_BASE_URL=http://worker:8090
+AUDIT_API_TIMEOUT_SECONDS=2.0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
AUDIT_API_BASE_URL=
AUDIT_API_TIMEOUT_SECONDS=2.0
# Required for Discord bot audit logging; points to the worker ingest API
AUDIT_API_BASE_URL=http://worker:8090
AUDIT_API_TIMEOUT_SECONDS=2.0
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 65-65: [UnorderedKey] The AUDIT_API_BASE_URL key should go before the CHECK_EMAIL_WAIT key

(UnorderedKey)


[warning] 66-66: [UnorderedKey] The AUDIT_API_TIMEOUT_SECONDS key should go before the CHECK_EMAIL_WAIT key

(UnorderedKey)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 65 - 66, AUDIT_API_BASE_URL is empty and lacks the
guidance comments used for other required envs; update the .env.example to set a
sensible local default (e.g., AUDIT_API_BASE_URL=http://worker:8090) and add a
short comment like "Required: URL of the Audit API (local dev default shown)"
similar to the existing comments for WEBHOOK_SHARED_SECRET and OPENAI_API_KEY so
developers know what to set and local-compose works out of the box; leave
AUDIT_API_TIMEOUT_SECONDS unchanged unless you want to document its units in a
one-line comment.

Comment on lines +1 to 2
# Pinned from ghcr.io/astral-sh/uv:python3.12-bookworm-slim (verified 2026-02-21).
FROM ghcr.io/astral-sh/uv@sha256:e5b65587bce7de595f299855d7385fe7fca39b8a74baa261ba1b7147afa78e58
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the digest matches the claimed tag and check for current tags

IMAGE="ghcr.io/astral-sh/uv"
DIGEST="sha256:e5b65587bce7de595f299855d7385fe7fca39b8a74baa261ba1b7147afa78e58"

echo "=== Fetching manifest for the pinned digest ==="
curl -fsSL \
  -H "Accept: application/vnd.oci.image.manifest.v1+json" \
  "https://ghcr.io/v2/astral-sh/uv/manifests/${DIGEST}" \
  2>/dev/null | jq '{mediaType, config: .config.digest}' || echo "Manifest fetch failed or digest not found"

echo ""
echo "=== Checking available tags for python3.12-bookworm variant ==="
# Query the Docker registry API to list tags
curl -fsSL \
  "https://ghcr.io/v2/astral-sh/uv/tags/list" \
  2>/dev/null | jq '.tags[] | select(contains("python3.12") and contains("bookworm"))' | head -20

Repository: 508-dev/508-workflows

Length of output: 177


🌐 Web query:

astral-sh uv Docker image python3.12-bookworm-slim ghcr.io releases

💡 Result:

  • Image / registry: ghcr.io/astral-sh/uv (Astral’s official GHCR package). The canonical place to browse all tags (“releases”) and digests is the GitHub Container package page (and its “Versions” list). [2]

  • Tag scheme (how releases are published):

    • Distroless / base tags (e.g. latest, 0.10.4, 0.10) and
    • Derived “Python base” images are tagged as:
      ghcr.io/astral-sh/uv:{major}.{minor}.{patch}-{base} and ghcr.io/astral-sh/uv:{major}.{minor}-{base}. [1]
  • For the specific base you asked about (python3.12-bookworm-slim):

    • You should expect version-pinned tags like:
      ghcr.io/astral-sh/uv:<UV_VERSION>-python3.12-bookworm-slim (example known tag: 0.5.26-python3.12-bookworm-slim). [4]
    • There was also discussion that the published tag name is python3.12-bookworm-slim (not python3.12-slim-bookworm). [3]

🌐 Web query:

uv package manager Docker image latest tags 2026

💡 Result:

As of Feb 21, 2026, the official uv Docker images are published as ghcr.io/astral-sh/uv and also mirrored on Docker Hub as docker.io/astral/uv. [1][2]

“Latest” uv version (what :latest points to)

  • The latest released uv version is 0.10.2 (released 2026-02-10), so ghcr.io/astral-sh/uv:latest / astral/uv:latest should correspond to that release. [2][3]

Tag schemes you can use (2026)

Distroless (just the uv binaries):

  • :latest
  • :{major}.{minor}.{patch} (example shown in docs: :0.10.2)
  • :{major}.{minor} (tracks latest patch in that minor line, e.g. :0.8) [2]

Derived OS / Python base images (uv preinstalled):

  • Alpine bases: :alpine, :alpine3.23, :alpine3.22, etc. [2]
  • Debian bases (current in docs): :debian, :debian-slim, :trixie, :trixie-slim [2]
  • Python bases (examples): :python3.14-alpine, :python3.13-alpine, :python3.14-trixie, :python3.14-trixie-slim, etc. [2]

Version-pinned derived images (recommended for reproducibility):

  • :{major}.{minor}.{patch}-{base} (example: :0.10.2-alpine)
  • :{major}.{minor}-{base} (example: :0.10-alpine) [2]

Clarify the uv version in the pinned image comment.

The digest pinning is correct for security. However, the referenced tag ghcr.io/astral-sh/uv:python3.12-bookworm-slim should include the uv version number for reproducibility and clarity. According to the uv Docker image tagging scheme, this should be formatted as ghcr.io/astral-sh/uv:{version}-python3.12-bookworm-slim (example: 0.10.2-python3.12-bookworm-slim for the latest release as of Feb 21, 2026). Update the comment to specify the exact version this digest corresponds to.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/discord_bot/Dockerfile` around lines 1 - 2, Update the Dockerfile
comment to state the exact uv image version that corresponds to the pinned
digest: replace the current tag reference
"ghcr.io/astral-sh/uv:python3.12-bookworm-slim" with the fully qualified tag
including the uv release (e.g.,
"ghcr.io/astral-sh/uv:0.10.2-python3.12-bookworm-slim") so the comment next to
the FROM line accurately documents which uv version the sha256 digest
represents.

Comment thread apps/worker/Dockerfile
Comment on lines +1 to 2
# Pinned from ghcr.io/astral-sh/uv:python3.12-bookworm-slim (verified 2026-02-21).
FROM ghcr.io/astral-sh/uv@sha256:e5b65587bce7de595f299855d7385fe7fca39b8a74baa261ba1b7147afa78e58
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

uv 0.9 release notes Docker images bookworm removed trixie

💡 Result:

  • In uv 0.9.0 (released 2025-10-07), the uv:debian and uv:debian-slim Docker images switched their base from Debian 12 (Bookworm) to Debian 13 (Trixie). The old base was still available via uv:bookworm and uv:bookworm-slim at that time. [1]
  • The Bookworm-tagged images were removed later, in uv 0.10.0 (released 2026-02-05): uv:bookworm and uv:bookworm-slim are “no longer published”, and you’re told to use uv:debian or uv:trixie instead. [2]

Sources
[1] astral-sh/uv v0.9.0 release notes (GitHub releases mirror) (phillipring.com)
[2] astral-sh/uv v0.10.0 release notes (GitHub releases) (github.com)

Citations:


🏁 Script executed:

#!/bin/bash
# Check if the bookworm SHA is still resolvable and get the python3.12-trixie digest
echo "=== Checking old bookworm-slim SHA digest ==="
curl -s -I \
  "https://ghcr.io/v2/astral-sh/uv/manifests/sha256:e5b65587bce7de595f299855d7385fe7fca39b8a74baa261ba1b7147afa78e58" \
  -H "Accept: application/vnd.oci.image.manifest.v2+json,application/vnd.oci.image.index.v1+json" \
  2>&1 | head -20

echo -e "\n=== Checking python3.12-trixie tag manifest ==="
curl -s -I \
  "https://ghcr.io/v2/astral-sh/uv/manifests/python3.12-trixie" \
  -H "Accept: application/vnd.oci.image.manifest.v2+json,application/vnd.oci.image.index.v1+json" \
  2>&1 | head -20

Repository: 508-dev/508-workflows

Length of output: 702


Correct the uv version and release date: bookworm-slim was removed in uv 0.10.0 (2026-02-05), not 0.9 (2026-01-29).

The Debian Bookworm Docker images were removed in uv 0.10.0 (released 2026-02-05), not uv 0.9. At uv 0.9.0 (released 2025-10-07), the bookworm-slim tag was still available. The comment on line 1 now documents an image tag that is no longer published. While the SHA digest pin may still resolve against cached GHCR layers, the underlying image is no longer maintained and won't receive security or OS-level patches.

Migrate to python3.12-trixie instead. Pin the new SHA256 digest after obtaining it—pinning specific digests is best practice for reproducible builds, as tags can be moved across different commit SHAs.

Proposed migration to python3.12-trixie
-# Pinned from ghcr.io/astral-sh/uv:python3.12-bookworm-slim (verified 2026-02-21).
-FROM ghcr.io/astral-sh/uv@sha256:e5b65587bce7de595f299855d7385fe7fca39b8a74baa261ba1b7147afa78e58
+# Pinned from ghcr.io/astral-sh/uv:python3.12-trixie (verified <DATE>).
+FROM ghcr.io/astral-sh/uv@sha256:<NEW_DIGEST>

Obtain the new digest by querying GHCR for the current python3.12-trixie manifest.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/Dockerfile` around lines 1 - 2, Update the base image comment and
FROM line so the Dockerfile no longer references the removed bookworm-slim tag:
replace the comment on the first line and the existing FROM
ghcr.io/astral-sh/uv@sha256:e5b65587... with a pinned python3.12-trixie image
digest (e.g., FROM ghcr.io/astral-sh/uv@sha256:<new-digest>), obtaining the
correct SHA256 by querying GHCR for the python3.12-trixie manifest; ensure the
comment reflects the uv 0.10.0 removal date and that the new digest is pinned
for reproducible builds.

Comment thread apps/worker/src/five08/worker/api.py Outdated
Comment thread apps/worker/src/five08/worker/api.py Outdated
actor_display_name: str | None = None
correlation_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
occurred_at: datetime | None = None
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider enforcing timezone-aware datetimes for occurred_at.

Using plain datetime allows naive (tz-unaware) values to be submitted. When persisted to a TIMESTAMP WITH TIME ZONE column in PostgreSQL, the session timezone is applied to naive datetimes, which can produce incorrect audit timestamps. Pydantic v2 exposes AwareDatetime precisely for this:

🛡️ Proposed fix
-from datetime import datetime
+from pydantic import AwareDatetime, BaseModel, Field

...

-    occurred_at: datetime | None = None
+    occurred_at: AwareDatetime | None = None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/five08/worker/models.py` at line 73, The occurred_at field
currently allows naive datetimes; change its type to pydantic.AwareDatetime to
enforce timezone-aware values, update the import to pull AwareDatetime from
pydantic, and keep the default None if you still want optionality (e.g.,
occurred_at: AwareDatetime | None = None) so validation will reject tz-naive
datetimes at assignment/parse time; locate the occurred_at definition in the
model class and replace the datetime type with AwareDatetime and add the import.

Comment thread docker-compose.yml
Comment on lines +83 to +86
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-workflows}
POSTGRES_URL: ${POSTGRES_URL}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Checking for POSTGRES_URL usage in Python files ==="
rg -n -C3 'POSTGRES_URL' --type=py

echo ""
echo "=== Searching for database connection patterns ==="
rg -n -C5 'create_engine|create_async_engine|AsyncSession|sessionmaker|get_db|database_url|db_url' --type=py | head -100

Repository: 508-dev/508-workflows

Length of output: 3350


POSTGRES_URL is required but has no default — migrations and non-local deployments will fail.

Lines 86 and 121 pass ${POSTGRES_URL} without a fallback. The application code explicitly requires this variable:

  • apps/worker/src/five08/worker/migrations/env.py line 22–23 raises RuntimeError("POSTGRES_URL is required for worker job migrations.") if the variable is missing.
  • packages/shared/src/five08/settings.py line 51–52 validates that POSTGRES_URL must be set for non-local environments.

Without a default in the compose file, migrations and non-local deployments will fail on fresh setups. Add a default:

Suggested fix
-      POSTGRES_URL: ${POSTGRES_URL}
+      POSTGRES_URL: ${POSTGRES_URL:-postgresql://postgres:postgres@postgres:5432/workflows}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-workflows}
POSTGRES_URL: ${POSTGRES_URL}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-workflows}
POSTGRES_URL: ${POSTGRES_URL:-postgresql://postgres:postgres@postgres:5432/workflows}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 83 - 86, The compose file exposes
POSTGRES_URL without a fallback which causes runtime errors where the app
expects it (see code that raises RuntimeError in migrations and settings
validation requiring POSTGRES_URL); update the docker-compose environment entry
for POSTGRES_URL to provide a sensible default fallback (e.g., a local Postgres
connection URI that matches POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB) so that
${POSTGRES_URL} becomes ${POSTGRES_URL:-<local-postgres-connection-string>} and
migrations/non-local deployments won't fail on fresh setups.

Comment thread packages/shared/src/five08/settings.py
Comment on lines 35 to 38
async def test_poll_inbox_handles_imap_errors(
self, email_monitor, mock_discord_channel, capfd
self, email_monitor_real_poll, mock_discord_channel
):
"""Test that IMAP errors are handled gracefully."""
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Docstring contradicts the test's actual assertion.

The docstring says "IMAP errors are handled gracefully," but the test asserts the error propagates with pytest.raises. The inline comment on line 44 already acknowledges the bubble-up behavior; the docstring should match.

🐛 Proposed fix
-    """Test that IMAP errors are handled gracefully."""
+    """Test that IMAP transport errors propagate out of the poll coroutine."""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def test_poll_inbox_handles_imap_errors(
self, email_monitor, mock_discord_channel, capfd
self, email_monitor_real_poll, mock_discord_channel
):
"""Test that IMAP errors are handled gracefully."""
async def test_poll_inbox_handles_imap_errors(
self, email_monitor_real_poll, mock_discord_channel
):
"""Test that IMAP transport errors propagate out of the poll coroutine."""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_email_monitor.py` around lines 35 - 38, The test
docstring for test_poll_inbox_handles_imap_errors is incorrect: it says IMAP
errors are "handled gracefully" but the test asserts the error propagates using
pytest.raises; update the docstring in tests/integration/test_email_monitor.py
for the async test_poll_inbox_handles_imap_errors to state that IMAP errors are
propagated/bubbled up (e.g., "Test that IMAP errors are propagated") so it
accurately reflects the assertion and the inline comment about bubble-up
behavior.

…schema

# Conflicts:
#	apps/worker/pyproject.toml
#	uv.lock
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (6)
apps/worker/src/five08/worker/migrations/versions/20260221_0100_create_jobs_table.py (1)

19-24: Consider adding a DB-level server_default for UUID generation on the id column.

The id column currently has no server_default, while other columns in the same table (attempts, max_attempts, created_at) do. Adding server_default=sa.text("gen_random_uuid()") provides a defensive safeguard against any future code path that might omit the UUID.

🛡️ Proposed addition of server_default
         sa.Column(
             "id",
             postgresql.UUID(as_uuid=True),
             nullable=False,
             primary_key=True,
+            server_default=sa.text("gen_random_uuid()"),
         ),

Note: gen_random_uuid() is built-in from PostgreSQL 13+; for older versions use uuid_generate_v4() (requires CREATE EXTENSION IF NOT EXISTS "uuid-ossp").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/worker/src/five08/worker/migrations/versions/20260221_0100_create_jobs_table.py`
around lines 19 - 24, The id column definition in the migration (sa.Column("id",
postgresql.UUID(as_uuid=True), nullable=False, primary_key=True)) should include
a DB-level default to defensively generate UUIDs; update that sa.Column call to
add server_default=sa.text("gen_random_uuid()") (or use
sa.text("uuid_generate_v4()") and ensure the uuid-ossp extension is created if
supporting older Postgres versions) so the database will emit a UUID even if the
application omits it.
packages/shared/src/five08/settings.py (1)

42-55: Validator correctly uses resolved model fields — previous os.getenv() issue addressed.

The self.postgres_url.strip() check is sound, but note it only catches an explicit POSTGRES_URL="" assignment. A production deployment that accidentally inherits the non-empty default (postgresql://postgres@postgres:5432/workflows) will pass validation silently. Only minio_root_password (default "") provides a meaningful guard.

If the intent is to detect "not explicitly configured for production," comparing against the known default is more reliable:

♻️ Optional: strengthen the postgres_url check
-        if not self.postgres_url.strip():
+        _local_default = "postgresql://postgres@postgres:5432/workflows"
+        if not self.postgres_url.strip() or self.postgres_url == _local_default:
             raise ValueError("POSTGRES_URL must be set when RUNTIME_ENV is non-local.")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared/src/five08/settings.py` around lines 42 - 55, The validator
validate_required_secrets currently only checks postgres_url for emptiness which
misses cases where the field equals the non-production default string; update
validate_required_secrets to also treat the known default
"postgresql://postgres@postgres:5432/workflows" as invalid for non-local
runtimes by comparing self.postgres_url.strip() against that default constant
(or a module-level DEFAULT_POSTGRES_URL) and raising ValueError if it matches,
keeping the existing minio_root_password check intact.
apps/worker/src/five08/worker/api.py (1)

317-332: sync_people_handler is missing error handling consistent with the other enqueue handlers.

espocrm_webhook_handler and espocrm_people_sync_webhook_handler both wrap their enqueue calls in try/except → 503. A queue/DB failure in _enqueue_full_crm_sync_job here will propagate as an unhandled 500 instead.

♻️ Proposed fix
     queue = request.app[QUEUE_KEY]
+    try:
         job = await _enqueue_full_crm_sync_job(queue, reason="manual")
+    except Exception:
+        logger.exception(
+            "Failed enqueueing CRM full-sync job queue=%s", settings.redis_queue_name
+        )
+        return web.json_response({"error": "enqueue_failed"}, status=503)
     return web.json_response(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/five08/worker/api.py` around lines 317 - 332,
sync_people_handler lacks the try/except used by other enqueue handlers
(espocrm_webhook_handler, espocrm_people_sync_webhook_handler) so failures in
_enqueue_full_crm_sync_job will surface as a 500; wrap the call to
_enqueue_full_crm_sync_job in a try/except that catches exceptions, logs or
records the error, and returns a web.json_response with status 503 and an
appropriate error payload (matching the pattern used by the other handlers) to
handle queue/DB failures gracefully.
tests/unit/test_worker_api.py (1)

168-186: Implicit AsyncMock auto-detection is inconsistent with adjacent tests.

Python 3.12 auto-selects AsyncMock for coroutine patches, so this works. However, the sibling test test_espocrm_people_sync_webhook_handler_enqueues_contact_jobs (line 204) uses new_callable=AsyncMock explicitly. Being consistent avoids relying on the auto-detection and makes the intent clearer.

♻️ Suggested change
-    with patch("five08.worker.api._enqueue_full_crm_sync_job") as mock_enqueue:
+    with patch(
+        "five08.worker.api._enqueue_full_crm_sync_job", new_callable=AsyncMock
+    ) as mock_enqueue:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_worker_api.py` around lines 168 - 186, The test uses patch on
five08.worker.api._enqueue_full_crm_sync_job without explicitly specifying
AsyncMock, relying on Py3.12 auto-detection; make it explicit for consistency
with nearby tests by using patch(..., new_callable=AsyncMock) when mocking
_enqueue_full_crm_sync_job in test_sync_people_handler_enqueues_full_sync and
then set mock_enqueue.return_value to the Mock(id="job-sync", created=True) as
before so the async call returns the expected job object.
tests/unit/test_shared_settings.py (1)

9-26: Tests are correct; consider adding coverage for the postgres_url validation branch and env bypass.

The two tests exercise the happy path and the minio_root_password whitespace guard well. Two gaps worth filling optionally:

  1. No test for an explicit postgres_url="" in a non-local env (the other ValueError branch).
  2. No test that runtime_env="dev" or runtime_env="test" skips validation entirely.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/test_shared_settings.py` around lines 9 - 26, Add two unit tests
to cover the missing validation branches: (1) a test function that creates
SharedSettings with runtime_env="production" and postgres_url="" (or whitespace)
and asserts ValidationError (or the specific ValueError message tied to
postgres_url) is raised, referencing the SharedSettings constructor used in
test_non_local_settings_require_non_empty_secrets; (2) a test function that
constructs SharedSettings with runtime_env="dev" (or "test") and
empty/whitespace postgres_url and minio_root_password and asserts no
ValidationError is raised (i.e., validation is skipped in non-production
environments), referencing the same SharedSettings class and its runtime_env
behavior to ensure env bypass coverage.
apps/worker/pyproject.toml (1)

9-11: Comment placement is slightly ambiguous — alembic is also pinned above it.

The comment on line 10 clearly applies to dramatiq[redis]==2.0.1, but alembic==1.18.4 on line 9 uses the same == pin style without explanation. Consider moving the comment directly above dramatiq or expanding it to cover both pins.

✏️ Suggested clarification
     "alembic==1.18.4",
-    # Pin intentionally for reproducible broker behavior across worker/api images.
     "dramatiq[redis]==2.0.1",
+    # Pin intentionally: dramatiq major version must match across worker/api images for protocol compatibility.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/pyproject.toml` around lines 9 - 11, The comment about
intentional pinning is ambiguous because it sits between "alembic==1.18.4" and
"dramatiq[redis]==2.0.1"; move the comment so it sits directly above
"dramatiq[redis]==2.0.1" or reword it to explicitly state which packages are
intentionally pinned (e.g., mention both "alembic" and "dramatiq[redis]") so
readers know whether the reproduction requirement applies to one or both pins.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/worker/src/five08/worker/api.py`:
- Around line 404-425: The try/except only catches ValueError from the
insert_audit_event call so DB errors (psycopg/asyncpg, constraint/connection
failures) bubble up as 500s; update the exception handling around the
asyncio.to_thread(insert_audit_event, ...) block to also catch database-related
exceptions (e.g., psycopg.Error or asyncpg.PostgresError and/or a broad
DatabaseError base) and convert them into a clear JSON error response (e.g.,
{"error":"database_error","detail":...}) with an appropriate status (502 or 500)
while logging the exception; also add a unit/integration test that mocks
insert_audit_event to raise a DB exception and asserts the handler returns the
expected JSON error response and status.

---

Nitpick comments:
In `@apps/worker/pyproject.toml`:
- Around line 9-11: The comment about intentional pinning is ambiguous because
it sits between "alembic==1.18.4" and "dramatiq[redis]==2.0.1"; move the comment
so it sits directly above "dramatiq[redis]==2.0.1" or reword it to explicitly
state which packages are intentionally pinned (e.g., mention both "alembic" and
"dramatiq[redis]") so readers know whether the reproduction requirement applies
to one or both pins.

In `@apps/worker/src/five08/worker/api.py`:
- Around line 317-332: sync_people_handler lacks the try/except used by other
enqueue handlers (espocrm_webhook_handler, espocrm_people_sync_webhook_handler)
so failures in _enqueue_full_crm_sync_job will surface as a 500; wrap the call
to _enqueue_full_crm_sync_job in a try/except that catches exceptions, logs or
records the error, and returns a web.json_response with status 503 and an
appropriate error payload (matching the pattern used by the other handlers) to
handle queue/DB failures gracefully.

In
`@apps/worker/src/five08/worker/migrations/versions/20260221_0100_create_jobs_table.py`:
- Around line 19-24: The id column definition in the migration (sa.Column("id",
postgresql.UUID(as_uuid=True), nullable=False, primary_key=True)) should include
a DB-level default to defensively generate UUIDs; update that sa.Column call to
add server_default=sa.text("gen_random_uuid()") (or use
sa.text("uuid_generate_v4()") and ensure the uuid-ossp extension is created if
supporting older Postgres versions) so the database will emit a UUID even if the
application omits it.

In `@packages/shared/src/five08/settings.py`:
- Around line 42-55: The validator validate_required_secrets currently only
checks postgres_url for emptiness which misses cases where the field equals the
non-production default string; update validate_required_secrets to also treat
the known default "postgresql://postgres@postgres:5432/workflows" as invalid for
non-local runtimes by comparing self.postgres_url.strip() against that default
constant (or a module-level DEFAULT_POSTGRES_URL) and raising ValueError if it
matches, keeping the existing minio_root_password check intact.

In `@tests/unit/test_shared_settings.py`:
- Around line 9-26: Add two unit tests to cover the missing validation branches:
(1) a test function that creates SharedSettings with runtime_env="production"
and postgres_url="" (or whitespace) and asserts ValidationError (or the specific
ValueError message tied to postgres_url) is raised, referencing the
SharedSettings constructor used in
test_non_local_settings_require_non_empty_secrets; (2) a test function that
constructs SharedSettings with runtime_env="dev" (or "test") and
empty/whitespace postgres_url and minio_root_password and asserts no
ValidationError is raised (i.e., validation is skipped in non-production
environments), referencing the same SharedSettings class and its runtime_env
behavior to ensure env bypass coverage.

In `@tests/unit/test_worker_api.py`:
- Around line 168-186: The test uses patch on
five08.worker.api._enqueue_full_crm_sync_job without explicitly specifying
AsyncMock, relying on Py3.12 auto-detection; make it explicit for consistency
with nearby tests by using patch(..., new_callable=AsyncMock) when mocking
_enqueue_full_crm_sync_job in test_sync_people_handler_enqueues_full_sync and
then set mock_enqueue.return_value to the Mock(id="job-sync", created=True) as
before so the async call returns the expected job object.

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.

1 participant