Skip to content

M2 Track T: project API keys and scheduled submission - #22

Merged
hutusi merged 16 commits into
mainfrom
feat/m2-triggers
Aug 4, 2026
Merged

hutusi merged 16 commits into
mainfrom
feat/m2-triggers

Conversation

@hutusi

@hutusi hutusi commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

M2 Track T, complete: the parts of the platform that let it run when nobody is watching. Three ways to start work without a person — a key, a clock, and an inbound event — over one shared submission path.

What's here

Project API keys (Authorization: Bearer agr_<key>) — issued and revoked by project admins under Settings → API keys, plaintext shown exactly once, verification reusing the daemon-token scheme (prefix-indexed lookup, constant-time compare) now extracted into one bearer-tokens module that the agrd_ and invitation tokens call too, replacing three near-identical copies.

Two decisions shape what a key can do:

  • Reach is an allow-list, not a denylist. A table of (method, path) → required scope names every endpoint a key may touch; anything unlisted is 403. A denylist fails open on every route added afterwards — a key issued today would silently gain tomorrow's admin endpoint. Administration is unreachable by construction, and the org role is pinned to org_member on key requests so an admin's key still administers nothing.
  • A key acts as its creating user and is bound to its project, so project membership stays the single source of access — revoke a membership and the keys stop with it. The binding is enforced inside assertProjectRole, which now takes the whole request principal instead of a bare user id. That signature change is the enforcement: handlers reach a project through a :projectId param, a loaded run, or a loaded task, and all of them land in that function.

Scheduled task submission — a task type, its parameters, and a five-field cron in an IANA timezone. pg-boss owns the calendar (and its DST edges); the worker re-registers every enabled schedule at boot, since the row and the calendar are written separately and a crash between them would leave a schedule that silently never fires.

The design turns on one distinction: conditions that will never heal on their own disable the schedule and announce it; everything else leaves it enabled and records the error. Owner lost access / project archived / task type withdrawn → stop, say why, audit, schedule.disabled. Quota exhausted / grant revoked / no capable worker → stay on, surface last_error, schedule.failed, try again.

The obvious alternative was to copy the API-key rule and let a schedule die with its owner's access. Rejected deliberately: a dead key is loud (a 401 within minutes), a dead schedule is silent, and silence is indistinguishable from "no schedule was ever created" — the worst property available to the one feature whose job is to run unattended. Authority is checked at fire time using the same member gate a human submit uses, rather than revoked eagerly, because eager revocation means hooking every path that can remove authority and missing one leaves a schedule firing with authority its owner no longer has.

Fire-time date tokens — pm.weekly-report takes a required free-text dateRange that reaches both the prompt and the artifact title, and the template expression language has no date support by design. Without tokens a weekly schedule reports on the same fixed week forever: the M2 acceptance criterion literally true and practically worthless. Nine tokens ({{today}}, {{lastWeekStart}}, {{lastMonthEnd}}, …) resolve in the schedule's timezone from one clock reading shared with lastFiredAt. The set is closed on purpose — the failure mode is quietly growing a second expression language beside the deliberately non-Turing-complete one in ADR-0006. Parameters are validated at creation against the compiled template, so a schedule that could never produce a run is a 400 rather than a schedule.failed a week later.

Schedules are created from a task (fill the normal submit form → "Run on a schedule"), so they carry exactly the parameters a real submission would; Settings → Schedules is the management view.

Inbound webhook triggers — a signed URL an external system POSTs to in order to start a task. Track N's outbound pipeline inverted, with the HMAC verified rather than generated, using the same x-agrippa-timestamp / x-agrippa-signature: v1=… convention the platform sends.

Three decisions shape it:

  • The token is in the URL path, not a header, because the senders that matter (CI runners, IM bots, hosted git providers) can all POST to a URL and many cannot set Authorization. That makes the URL a capability, which is exactly why the signing secret is mandatory here although it is optional on outbound IM endpoints: an unsigned inbound trigger is an open "spend this project's tokens" endpoint for anyone who ever sees the URL in a proxy log or a CI transcript. The signature covers the raw request bytes, and timestamps outside ±5 minutes are refused in both directions.
  • The request records and acknowledges; the worker submits. The delivery row is written before the 202, so a failed submission stays visible and replayable instead of vanishing with the sender's connection, and a slow submit never becomes a timeout the sender retries into a duplicate run. An optional x-agrippa-delivery-id makes that idempotent outright, and fireTrigger refuses an already-succeeded delivery — at-least-once job delivery and an operator hitting replay converge on one run.
  • Because the sender is acknowledged before the run exists, it can never learn that the run did not happen. trigger.disabled / trigger.failed notifications are therefore the only channel that reaches a human — the same argument that made schedules disable loudly rather than skip. Both now share checkWorkAuthority.

The received payload is stored for inspection and never interpolated into a prompt: a valid signature proves who sent the bytes, not that the bytes are safe. Mapping payload fields into parameters is deliberately out of scope — it is a prompt-injection surface deserving its own design.

Also in here

  • Fix: archiving a project now actually stops new work. project.archive has always written projects.status and nothing read it — the only two references to the value were the write itself — while both manuals promised it "stops accepting submissions". Survivable while a human had to click Submit; not once work arrives unattended, since an archived project is precisely the one nobody is watching.
  • Refactor: submitTask, quota, and worker-liveness move to @agrippa/orchestration. The schedule fire handler runs in the worker, which cannot import the api. None of these ever had an HTTP dependency; they were in apps/api/src/lib/ because that is where the first caller happened to live.

Verification

bun run check, bun test, bun run templates:validate, bun run build green at every commit. 549 pass / 5 skip / 0 fail — the 5 are the pre-existing compliance cases that don't run through the remote transport. (An earlier run showed 116 skips because the dev stack was holding Postgres connections; that is the "0 fail with skips is not a pass" trap, re-run clean with the stack stopped.)

New coverage: 14 trigger integration tests (one-time token, uniform 401 across unknown token / wrong secret / malformed signature / stale + future timestamp / disabled trigger, raw-byte signing, delivery-id dedupe, payload cap, owner-lost-access and archived-project disable, no-double-submit on redelivery, delivery log and failed-only replay), 15 schedule integration tests (CRUD, cron/timezone rejection, admin gating, all three concurrency policies, each disable reason, transient-failure-does-not-disable, token resolution), 10 API-key integration tests (one-time plaintext, uniform 401 across unknown/revoked/expired, per-route scopes, blanket 403 across eleven admin surfaces, cross-project denial, audit attribution, key dies with its owner's membership), and unit tests for the cron validator and the token date math (DST crossing, leap year, Monday-based weeks, timezone-not-server).

Driven in a real browser against a live stack, not only in tests. For schedules: filled the weekly-report form with a token, created the schedule, sent a schedule.fire job through the running worker — the run's paramsSnapshot carried dateRange: "2026-07-27..2026-08-02" while the schedule row kept the token. For triggers: created one through the browser, POSTed a correctly signed request (202) plus wrong-signature and stale-timestamp ones (both the same uniform 401), repeated with the same delivery id (deduplicated, one delivery row), and confirmed the worker produced a run.

That browser smoke earned its keep: it caught a bug the unit tests missed. Trigger creation validates the resolved parameters (sharing the schedule validator), so it accepted a {{lastWeekStart}} token — but firing passed endpoint.params straight through and the token reached the agent's prompt verbatim. Triggers now carry a timezone and resolve tokens at fire time exactly as schedules do, with a regression test asserting the run's snapshot holds dates while the stored parameter keeps the token.

Noted, not fixed here

runs.params_snapshot and tasks.params are stored as jsonb strings, not objects — jsonb_typeof returns string for every existing row, including ones predating this branch. Runtime is unaffected (drizzle round-trips it), but params_snapshot->>'key' returns null, so SQL over params silently yields nothing. Pre-existing; needs a migration plus backfill, so it wants its own change rather than being buried here.

Summary by CodeRabbit

  • New Features
    • Added recurring task schedules with cron, time zones, date tokens, concurrency controls, and lifecycle notifications.
    • Added signed inbound webhooks with replay protection, deduplication, delivery history, retries, payload limits, and asynchronous execution.
    • Added project API keys with scopes, expiration, revocation, one-time display, and audit tracking.
    • Added settings and submission-page controls for managing schedules, webhooks, and API keys.
  • Bug Fixes
    • Improved unattended submissions, delivery reliability, idempotency, and queue error handling.
    • Archived projects now reject submissions and retries while preserving history.
  • Documentation
    • Added administration guidance for automation, authentication, and archived-project behavior.

hutusi added 6 commits August 3, 2026 16:04
Submission logic lived inline in the Hono handler, closed over the request
context for the db, the session user, the queue, and audit. That is fine for
the browser path and impossible for every other caller Track T introduces: a
cron schedule and an inbound webhook have no `Context`, and an API-key request
has a principal that is not a session user.

So the body moves to `lib/submit.ts` as `submitTask(db, queue, args)` and the
route becomes a thin adapter. Nothing about the sequence changes — quota
headroom, repo-ref verification, resource authorization, the single
transaction that writes task + run + latestRunId + the audit row together, and
the post-commit enqueue all keep their order and their comments.

Two supporting moves make that possible:

- `audit()` grew `auditAs(db, actor, entry)` underneath it, taking the actor
  explicitly instead of reading `c.var.user`. audit_logs already has a column
  per actor kind (user, api key, runtime); only the helper insisted on a
  session. The daemon surface had to route around this with a private copy,
  and the API-key path would have been the third — so the generalization is
  overdue rather than speculative.
- `resolveRunPlan()` moves to `lib/run-plan.ts`, since submit and retry both
  call it and it can no longer live in the route module.

Attribution is worth stating because it constrains Track T: tasks.created_by
and runs.created_by are NOT NULL → users.id, so automated submissions cannot
be authorless. `actorUserId` is separate from the audit `actor` for exactly
that reason — a schedule's runs are attributed to the schedule's owner while
the audit row can still name the key or trigger that fired it.

Pure extraction, no behavior change: the existing api suite (117 tests) passes
untouched.
…ng (M2 Track T2)

The `api_keys` table has existed since 0000_init and nothing ever read it; the
design doc has described `Bearer agr_<key>` since M1. This implements it, and
the shape of the implementation is mostly a set of decisions about what a
credential should *not* be able to do.

Reach is an allow-list, not a denylist. A table of (method, path) → required
scope names every endpoint a key may touch; anything unlisted is 403. The
alternative — "a key can do whatever its owner can, minus a denylist" — fails
open on every route added afterwards, which is the failure mode that matters
here: a key issued today would silently gain tomorrow's admin endpoint. So
administration (registries, template publish, members, quota, settings,
notification endpoints, checkpoint decisions, and key management itself) is
unreachable by construction rather than by omission, and the org role is pinned
to org_member on key requests so an admin's key still administers nothing.

A key acts as its creating user and is bound to its project. That keeps project
membership the single source of access — revoking someone's membership stops
their keys with it, which is also how you disable a departing teammate's
automation. The binding is enforced inside assertProjectRole, which now takes
the whole RequestPrincipal instead of a bare user id. That signature change is
the point: handlers reach a project through a :projectId param, a loaded run,
or a loaded task, and all of them land in that function, so the check cannot be
bypassed by forgetting it at one call site. The compiler enforces the rest.

Audit-on-use needed no new machinery once the actor was generalized in T1:
mutations already wrote an audit row, and they now carry actor_api_key_id
alongside actor_user_id, so a leaked key is distinguishable from its owner
working in the browser. `last_used_at` advances at most once a minute — it is a
liveness signal for the UI, not the audit trail, and an UPDATE in front of every
read would be a poor trade for a timestamp nobody reads to the second.

Verification reuses the daemon-token scheme rather than inventing a second one:
prefix-indexed lookup then constant-time compare, with unknown, malformed,
revoked, and expired all answering the same 401 so a probe learns nothing from
the difference. The generate/hash/compare trio existed in three near-identical
copies (runtime tokens, invitation tokens, and this would have been the third);
it is now one `bearer-tokens` module they all call.

Migration 0023 adds the unique index on api_keys.prefix that the lookup needs —
unique so a prefix collision can never make resolution ambiguous, mirroring
runtimes_token_prefix_uq.

Covered by apps/api/src/test/api-keys.integration.test.ts: one-time plaintext,
hash-only at rest, admin gating, uniform 401 across unknown/revoked/expired,
per-route scope enforcement, blanket 403 across eleven admin surfaces,
cross-project denial, audit attribution, and a key dying with its owner's
membership. Docs updated in design/05 and both manual locales.
`project.archive` has always written `projects.status = 'archived'` and nothing
has ever read it — grepping `apps/api/src` and `packages/orchestration/src` for
the value turns up exactly two hits, both the write itself. Meanwhile both
manual locales promise that archiving "stops accepting submissions". In
practice the project only dropped out of the switcher's active list; a submit
or a retry against it still went through and consumed quota.

That gap was tolerable while every submission required a human to click a
button in a UI that hid the project. It stops being tolerable now that work can
arrive without one: an archived project is by definition the one nobody is
looking at, so unattended runs would accumulate there unseen, which is the
worst place for them to accumulate.

Submit and retry now both call `assertProjectAcceptsWork` and answer
`409 project_archived`. Reads are deliberately untouched — archiving preserves
history rather than hiding it, and the manual's promise is about new work.

Landing this before schedules rather than with them: it is the fire-time check's
most important case, and it is a bug on its own terms today.
…rchestration

T1 put `submitTask()` in `apps/api/src/lib/` and noted it should move to
`packages/orchestration` only if the worker ever needed it. Cron schedules need
it: the fire handler runs in the worker, and `scripts/check-deps.ts` rightly
forbids the worker importing the api.

Moved, unchanged: `submit.ts`, `run-plan.ts`, `usage.ts` (both the reporting
query and the quota gate — `assertQuotaHeadroom` calls `projectUsage`, so
splitting them would have put one month-window definition in two packages), and
`executors.ts` → `worker-executors.ts` (renamed on arrival; `executors` next to
core's `EXECUTOR_CATALOG` would have read as the catalog rather than as which
workers are currently alive). `audit.ts` splits along the seam T1 opened: the
db-writing `auditAs` goes to orchestration, the Hono wrappers `audit`/
`requestActor` stay in the api.

None of these ever had an HTTP dependency — they import only core, db, and
drizzle. They were in `apps/api/src/lib/` because that is where the first
caller happened to live, and this corrects that rather than accommodating the
worker: "is a project over quota" and "which executors are live" are facts
about runs, not about requests.

Pure move. Behavior, signatures, and comments are unchanged; the full suite
(522 pass, 5 pre-existing skips) and the dependency-direction check confirm it.
A task type, its parameters, and a five-field cron in an IANA timezone.
pg-boss owns the calendar, so DST edges are the queue's problem rather than
ours; the worker re-registers every enabled schedule at boot, because the row
and the calendar are written separately and a crash between them would leave a
schedule that never fires — invisible by nature.

The design turns on one distinction: conditions that will never heal on their
own disable the schedule and announce it, everything else leaves it enabled and
records the error. An owner who left the project, an archived project, and a
withdrawn task type are permanent — the schedule stops, says why on its row,
audits, and emits schedule.disabled. An exhausted quota, a revoked grant, or no
worker for the required executor are fixable — the schedule stays on, surfaces
last_error, emits schedule.failed, and tries again next time.

Disabling is deliberately loud. The obvious alternative was to copy the API-key
rule and let a schedule die with its owner's access, but the two have opposite
failure signatures: a dead key is loud (the script gets a 401 within minutes),
while a dead schedule is silent, and silence is indistinguishable from "no
schedule was ever created". That is the worst property available to the one
feature whose entire job is to run while nobody is watching — you would find
out three Mondays later.

Authority is checked at fire time rather than revoked eagerly, using the same
member gate the manual submit path applies, so a schedule does exactly what its
owner could do right now and never more. Eager revocation means hooking every
path that can remove authority — membership removal, role downgrade, archive,
org-role change — and missing one leaves a schedule firing with authority its
owner no longer has. One check at the point of use cannot be forgotten. Runs
are attributed to that owner because runs.created_by is NOT NULL: unattended
work still names a human, which is what makes the check meaningful in audit.

Two details worth recording:

- `replace` cancels through a new `requestRunCancellation` helper rather than
  finalizing directly. Cancellation is cooperative for a RUNNING run — the
  engine checks the flag at its next step boundary — so a hard finalize would
  race an executor mid-invocation and strand its usage accounting. The helper
  is the API cancel handler's semantics, extracted rather than reimplemented.
- Schedule notifications have no run behind them, so they reuse the run-less
  delivery shape test sends already established (`run_id`/`event_id` null)
  instead of widening the schema or inventing a fictional run.

Covered by apps/api/src/test/schedules.integration.test.ts (11 tests: CRUD,
cron/timezone rejection, admin gating, all three concurrency policies, each
disable reason, and the transient-failure case proving the schedule survives an
exhausted quota) plus a unit suite for the cron validator. Docs in design/04
and both manual locales.
…e tokens

T3 shipped the machinery but not a schedule you could usefully create. Three
gaps, in order of how much they mattered.

**Parameters were frozen, and the flagship scheduled task needs them not to
be.** `pm.weekly-report` takes a required free-text `dateRange` that is
interpolated into both the prompt and the artifact title, and the template
expression language is pure path resolution — no `now`, no date arithmetic, by
design. So a weekly schedule would have produced "Weekly Report —
2026-07-27..2026-08-02" every Monday forever, telling the agent to report on
the same fixed week each time: the M2 acceptance criterion literally true and
practically worthless.

Nine fire-time tokens fix that ({{today}}, {{lastWeekStart}}, {{lastMonthEnd}},
…), substituted into string parameters when the schedule fires and resolved as
calendar days in the schedule's own timezone, from one clock reading shared
with lastFiredAt so a report cannot be stamped with a different day than it
covers. The set is closed on purpose — no arithmetic, no formats, no nesting.
The failure mode here is quietly growing a second, more capable expression
language beside the one ADR-0006 deliberately made non-Turing-complete, so the
list lives in core as a const and adding to it is a deliberate edit.

**Bad parameters surfaced a week late.** Creation now validates the *resolved*
parameters against the same compiled schema the submit form renders from, and
rejects unknown tokens outright — a `{{lastWeek}}` typo would otherwise reach
the agent's prompt verbatim and read as a broken report rather than a broken
schedule. Editing re-validates for the same reason. "Loud but late" is barely
better than silent when the whole point is unattended operation.

**Creation lived in the wrong place.** The settings form could not render a
task's parameter form, so it could only ever create schedules that failed. That
form, the preflight check, and the agent-slot picker all already exist on the
submit page, and "this task, repeatedly" is the honest mental model — so
"Run on a schedule" goes there, gated on the project-admin role the API
requires, and Settings → Schedules becomes purely the management view it is
good at: pause, resume, delete, see why one stopped. The create form is gone
rather than left half-working.

Verified in a real browser, not just in tests: filled the weekly-report form
with a token, opened the dialog (all copy resolved, no leaked i18n keys),
created the schedule, landed on the management view, then sent a schedule.fire
job through the running worker — the resulting run's paramsSnapshot carried
`dateRange: "2026-07-27..2026-08-02"`, last Mon-Sun relative to a Monday
firing, while the schedule row kept the token.

Date math is covered directly (packages/core/src/schedules.test.ts): Monday-based
weeks, Sunday as a week end, timezone-not-server resolution, a DST crossing, and
month/leap-year boundaries.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Added project-scoped API keys, recurring schedules, signed inbound webhook triggers, shared task submission orchestration, archive enforcement, worker processing, persistence, web controls, tests, documentation, and translations.

Changes

Automation platform

Layer / File(s) Summary
Authentication and submission
apps/api/src/context.ts, apps/api/src/middleware/*, apps/api/src/routes/api-keys.ts, apps/api/src/routes/execution.ts, packages/orchestration/src/submit.ts
Added project-bound API-key authentication, scoped routes, audit attribution, unified submission, post-commit queueing, and archived-project enforcement.
Schedules and webhook triggers
apps/api/src/routes/schedules.ts, apps/api/src/routes/triggers.ts, apps/api/src/routes/trigger-inbound.ts, packages/orchestration/src/schedule-fire.ts, packages/orchestration/src/trigger-fire.ts, apps/worker/src/index.ts
Added schedule CRUD, calendar reconciliation, token resolution, concurrency handling, signed ingress, durable deduplication, asynchronous processing, retries, and authority checks.
Contracts, persistence, and validation
packages/core/*, packages/db/*, packages/orchestration/*
Added shared API-key, schedule, trigger, queue, notification, audit, validation, and database contracts.
Web management and supporting content
apps/web/src/*, packages/i18n/*, docs/*, CHANGELOG.md
Added creation dialogs, settings controls, delivery management, translations, documentation, and release notes.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • ainaive/agrippa#2: Both changes modify task-submission handling and authorization.
  • ainaive/agrippa#9: Both changes modify submission and retry orchestration with authority checks.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies project API keys and scheduled submission, which are major changes in the pull request, but it omits inbound webhook triggers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/m2-triggers

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.

@hutusi
hutusi marked this pull request as ready for review August 3, 2026 09:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 18

🧹 Nitpick comments (21)
apps/api/src/test/api-keys.integration.test.ts (1)

255-270: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Do not delete shared membership in the last test.

This test deletes the admin's project_members row for projectId. All other tests in the file depend on that membership. The suite passes only because this test runs last. Any test added after it fails. Restore the membership afterwards, or run this case against a dedicated project.

♻️ Proposed change
   it("stops working when its owner loses project membership", async () => {
+    const scopedProjectId = (
+      await jsonOf<{ id: string }>(
+        await admin.request("/api/v1/projects", {
+          method: "POST",
+          json: { slug: "keys-membership", name: "keys-membership" },
+        }),
+      )
+    ).id;
-    const key = await issueKey(["runs:read"]);
-    expect((await withKey(key.key as string, `/projects/${projectId}/tasks`)).status).toBe(200);
+    const key = await issueKey(["runs:read"], scopedProjectId);
+    expect((await withKey(key.key as string, `/projects/${scopedProjectId}/tasks`)).status).toBe(
+      200,
+    );
 
     const [owner] = await db.select().from(apiKeys).where(eq(apiKeys.id, key.id));
     await db
       .delete(projectMembers)
       .where(
         and(
-          eq(projectMembers.projectId, projectId),
+          eq(projectMembers.projectId, scopedProjectId),
           eq(projectMembers.userId, owner?.createdBy as string),
         ),
       );
 
-    expect((await withKey(key.key as string, `/projects/${projectId}/tasks`)).status).toBe(403);
+    expect((await withKey(key.key as string, `/projects/${scopedProjectId}/tasks`)).status).toBe(
+      403,
+    );
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/api-keys.integration.test.ts` around lines 255 - 270,
Update the test “stops working when its owner loses project membership” to avoid
permanently deleting the shared project_members row: either create and use a
dedicated project for this scenario or restore the owner’s membership after
asserting the 403 response. Keep the membership state required by other tests
intact regardless of test execution order.
apps/api/src/middleware/auth.ts (1)

52-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Give the two API-key denials distinct stable codes.

Both denials use AppError.forbidden, so both answer with code forbidden. A client cannot distinguish "this endpoint is closed to API keys" from "this key lacks a scope", and the localized message cannot differ by case. Line 57 also interpolates the scope into the message, which a locale resource cannot reproduce from the code alone. Use distinct codes and move the scope name into details.

♻️ Proposed change
   const needed = requiredScopeFor(c.req.method, c.req.path);
   if (!needed) {
-    throw AppError.forbidden("This endpoint is not available to API keys");
+    throw new AppError(
+      "api_key_route_forbidden",
+      403,
+      "This endpoint is not available to API keys",
+    );
   }
   if (!row.scopes.includes(needed)) {
-    throw AppError.forbidden(`This API key lacks the ${needed} scope`);
+    throw new AppError("api_key_scope_missing", 403, `This API key lacks the ${needed} scope`, {
+      scope: needed,
+    });
   }

Add the matching en and zh-CN entries in the errors namespace for the new codes.

As per coding guidelines: "API error message values must localize from the errors namespace using a stable error code."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/middleware/auth.ts` around lines 52 - 58, Update the API-key
denial branches in the authentication middleware to use distinct stable error
codes for endpoint-unavailable and missing-scope cases. Move the required scope
from the interpolated message into the error details so each message can
localize solely through its code, and add matching English and zh-CN entries in
the errors namespace for both codes.

Source: Coding guidelines

apps/api/src/routes/api-keys.ts (1)

57-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fail explicitly when the insert returns no row.

row is typed as possibly undefined. The handler then writes an audit row with resourceId: undefined and answers 201 with { key } and no key metadata. The sibling schedule route guards the same case with an explicit throw. Add the same guard so the response shape cannot degrade silently.

♻️ Proposed change
         .returning(apiKeyView);
+      if (!row) throw new Error("api key insert failed");
       await audit(c, {
         action: "project.apikey.create",
         resourceType: "api_key",
-        resourceId: row?.id,
+        resourceId: row.id,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/api-keys.ts` around lines 57 - 78, In the API key
creation handler, add an explicit guard immediately after the insert and before
audit creation, using the returned row from the insert operation. Throw the same
established error used by the sibling schedule route when no row is returned,
and preserve the existing audit and 201 response flow for successful inserts.
apps/api/src/routes/catalog.ts (1)

3-3: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

DEFAULT_EXECUTOR is redefined in two routes. packages/orchestration/src/run-plan.ts exports DEFAULT_EXECUTOR as process.env.AGRIPPA_EXECUTOR ?? "claude-agent-sdk". Two API routes declare the same expression locally, and both carry comments stating they must resolve the executor exactly as submit does. Three definitions can drift.

  • apps/api/src/routes/catalog.ts#L3-L3: add DEFAULT_EXECUTOR to the @agrippa/orchestration import and delete the local declaration at line 8.
  • apps/api/src/routes/projects.ts#L38-L43: add DEFAULT_EXECUTOR to the same import and delete the local defaultExecutor at line 638.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/catalog.ts` at line 3, Reuse the centralized
DEFAULT_EXECUTOR from `@agrippa/orchestration` instead of redefining it locally.
In apps/api/src/routes/catalog.ts at lines 3-3, import DEFAULT_EXECUTOR and
remove the local declaration at line 8; in apps/api/src/routes/projects.ts at
lines 38-43, add the same import and remove the local defaultExecutor
declaration at line 638, updating references as needed.
apps/api/src/test/api.integration.test.ts (1)

194-206: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add retry coverage for the archived project.

apps/api/src/routes/execution.ts now calls assertProjectAcceptsWork on POST /tasks/:id/retry. This test covers submission only. A retry of the pre-existing task should also return 409 with project_archived. Add that assertion so the new retry guard cannot regress unnoticed.

As per coding guidelines: "API request mutations must use shared schemas and produce audit records; endpoint changes require integration coverage."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/api.integration.test.ts` around lines 194 - 206, Extend the
archived-project scenario around the existing submit assertion to call POST
/tasks/:id/retry for the pre-existing task, then assert a 409 response with
error code project_archived. Reuse the existing project and task identifiers and
request helpers so retry coverage verifies the assertProjectAcceptsWork guard
without changing the submission or history checks.

Source: Coding guidelines

packages/orchestration/src/submit.ts (1)

106-118: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Derive tasks.orgId from the verified project row.

submitTask trusts actor.orgId, but assertProjectAcceptsWork only checks projects.status and assertProjectRole only checks project membership and API-key binding. Use projects.orgId returned or selected at that check so org-scoped audit and usage rows cannot diverge from the target project.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/submit.ts` around lines 106 - 118, Update
submitTask and the project verification flow around assertProjectAcceptsWork to
retain the verified project row, then set the tasks insert’s orgId from that
row’s projects.orgId instead of actor.orgId. Preserve the existing project-role
and work-acceptance checks while ensuring downstream org-scoped records use the
target project’s organization.
packages/orchestration/src/usage.ts (1)

95-109: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid the full usage aggregate in the quota gate.

assertQuotaHeadroom reads only usage.tokens, but projectUsage runs five queries: totals, byModel, byTaskType (three joins), the clock row, and byDay. Four of them are discarded. This gate runs on every task submission (submitTask) and on every schedule firing, so the extra round trips and the three-table join are paid on the hot path.

Query the month total directly and keep projectUsage for the display endpoint.

♻️ Proposed refactor to query only the total
+/** Month-window token total only — what the quota gate needs. */
+async function periodTokens(db: Db, projectId: string): Promise<number> {
+  const [totals] = await db
+    .select({
+      tokens: sql<string>`coalesce(sum(${tokenUsage.inputTokens} + ${tokenUsage.outputTokens}), 0)`,
+    })
+    .from(tokenUsage)
+    .where(and(eq(tokenUsage.projectId, projectId), gte(tokenUsage.occurredAt, periodStart)));
+  return Number(totals?.tokens ?? 0);
+}
+
 export async function assertQuotaHeadroom(db: Db, projectId: string): Promise<void> {
   const [quota] = await db
     .select()
     .from(projectQuotas)
     .where(eq(projectQuotas.projectId, projectId));
   if (!quota?.hardStop || quota.tokenLimit === null) return;
 
-  const usage = await projectUsage(db, projectId);
-  if (usage.tokens >= quota.tokenLimit) {
+  const tokens = await periodTokens(db, projectId);
+  if (tokens >= quota.tokenLimit) {
     throw new AppError("quota_exhausted", 400, "The project's token quota is exhausted", {
-      tokens: usage.tokens,
+      tokens,
       tokenLimit: quota.tokenLimit,
     });
   }
 }

projectUsage can then call periodTokens for its own tokens field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/usage.ts` around lines 95 - 109, Update
assertQuotaHeadroom to query the current month’s token total directly instead of
calling projectUsage, preserving the existing quota limit comparison and error
behavior. Reuse the existing periodTokens helper or equivalent total-query
symbol, and update projectUsage to obtain its tokens field through periodTokens
while retaining its other display metrics.
apps/api/src/lib/bearer-tokens.ts (1)

47-51: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the prefix-length assumption in issueToken.

issueToken derives tokenPrefix from the concatenated token, so the stored prefix contains DISPLAY_PREFIX_LENGTH characters counted from the scheme tag. The current tags agr_ and agrd_ are shorter than 12 characters, so each stored prefix stays unique. If a future caller passes a tag of 12 or more characters, every issued token produces the same prefix, and the unique prefix indexes reject the second issuance. Add an invariant check so the failure appears at the call site.

🛡️ Proposed guard
 export function issueToken(prefix: string): IssuedToken {
+  if (prefix.length >= DISPLAY_PREFIX_LENGTH) {
+    throw new Error(
+      `token prefix "${prefix}" must be shorter than DISPLAY_PREFIX_LENGTH (${DISPLAY_PREFIX_LENGTH}) so stored prefixes stay unique`,
+    );
+  }
   const token = prefix + generateSecret();
   return { token, tokenPrefix: tokenPrefixOf(token), tokenHash: hashToken(token) };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/lib/bearer-tokens.ts` around lines 47 - 51, Update issueToken to
validate that the supplied prefix is shorter than DISPLAY_PREFIX_LENGTH before
concatenating and deriving tokenPrefix. Throw an invariant error at the call
site when the prefix is too long, while preserving the existing token generation
and hashing behavior for valid prefixes.
apps/web/src/lib/types.ts (1)

377-377: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse the shared ScheduleConcurrencyPolicy type.

packages/core/src/schedules.ts already derives ScheduleConcurrencyPolicy from SCHEDULE_CONCURRENCY_POLICIES. SubmitTaskPage.tsx imports it. This local union duplicates that contract and will drift if a policy is added to core.

♻️ Proposed refactor
-  concurrencyPolicy: "skip" | "queue" | "replace";
+  concurrencyPolicy: ScheduleConcurrencyPolicy;

Add the import at the top of the file:

import type { ScheduleConcurrencyPolicy } from "`@agrippa/core`";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/types.ts` at line 377, Update the type containing
concurrencyPolicy in types.ts to use the shared ScheduleConcurrencyPolicy
imported from `@agrippa/core` instead of the duplicated string union, preserving
the existing property name and contract.
apps/web/src/pages/SettingsPage.tsx (1)

1290-1294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the switch accessible name identify the schedule.

Every row uses the same aria-label value, "Enabled". A screen reader announces identical switches for all schedules, so the user cannot tell which schedule a switch controls.

♿ Proposed fix
               <Switch
                 checked={row.enabled}
-                aria-label={t("settings:schedules.toggle")}
+                aria-label={`${t("settings:schedules.toggle")} — ${row.name}`}
                 onCheckedChange={(on) => update.mutate({ id: row.id, body: { enabled: on } })}
               />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/SettingsPage.tsx` around lines 1290 - 1294, Update the
Switch in the schedule row to include the specific schedule’s identifying name
in its accessible label, rather than using the shared settings:schedules.toggle
translation alone. Preserve the existing checked state and update.mutate
behavior while ensuring each row’s control is distinguishable to screen readers.
docs/design/04-execution-runtime.md (1)

142-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the scheduled-submission material under its own heading, and mention edit-time validation.

Two points.

  1. These three paragraphs sit under the ### Notifications (outbound) heading on line 140, and line 148 returns to the notifications topic. Scheduled submission is a separate subject. A reader scanning headings will not find it here. A ### Scheduled submission heading, placed near the schedule.fire bullet on line 51, would match the document's structure.

  2. Line 144 says creation rejects unknown tokens and validates the resolved parameters. The PATCH handler applies the same check on edit (apps/api/src/routes/schedules.ts lines 157-170). State that both creation and edit validate, so a reader does not assume an edit can bypass the check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/04-execution-runtime.md` around lines 142 - 147, Move the three
scheduled-submission paragraphs out from under “### Notifications (outbound)”
into a new “### Scheduled submission” section near the existing schedule.fire
bullet, preserving their content and order. In the token-validation paragraph,
explicitly state that both schedule creation and PATCH edits reject unknown
tokens and validate resolved parameters against the compiled schema.
packages/core/src/schedules.test.ts (1)

153-162: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a parity test between SCHEDULE_TOKENS and scheduleTokenValues.

findUnknownScheduleTokens classifies a token as known using SCHEDULE_TOKENS. applyScheduleTokens substitutes using the keys of scheduleTokenValues. Nothing in this suite ties the two together. If a token is added to SCHEDULE_TOKENS without a matching value, the API accepts it at apps/api/src/routes/schedules.ts line 44 and the substitution then writes undefined into the agent prompt. The same list is printed to the user as the known set, so drift also produces a misleading error message.

💚 Proposed test
+  it("keeps SCHEDULE_TOKENS and the resolved values in step", () => {
+    const values = scheduleTokenValues(at, "UTC");
+    expect([...SCHEDULE_TOKENS].sort()).toEqual(Object.keys(values).sort());
+    for (const token of SCHEDULE_TOKENS) {
+      expect(applyScheduleTokens({ v: `{{${token}}}` }, at, "UTC").v).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+    }
+  });

Add SCHEDULE_TOKENS to the import on lines 2-8.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/schedules.test.ts` around lines 153 - 162, Add a parity
test in schedules.test.ts that imports SCHEDULE_TOKENS and verifies every token
classified as known by findUnknownScheduleTokens has a corresponding key in
scheduleTokenValues used by applyScheduleTokens. Keep the assertion focused on
detecting drift between these two token sets.
apps/api/src/test/schedules.integration.test.ts (6)

407-423: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Give the re-enable test an error to clear.

The test name says the re-enable clears the reason. The schedule created on line 408 never failed, so disabledReason and lastError are already null before the PATCH on line 415. The assertions on lines 421-422 would pass even if the PATCH handler dropped the clearing logic on lines 186-188 of apps/api/src/routes/schedules.ts. Set the columns first so the test measures the clearing.

💚 Proposed change
     expect(await fireSchedule(db, queue, row.id)).toEqual({ kind: "skipped", reason: "disabled" });
 
+    await db
+      .update(taskSchedules)
+      .set({ disabledReason: "owner_lost_access", lastError: "boom", lastErrorAt: new Date() })
+      .where(eq(taskSchedules.id, row.id));
     await admin.request(`/api/v1/projects/${projectId}/schedules/${row.id}`, {
       method: "PATCH",
       json: { enabled: true },
     });
     const after = await rowOf(row.id);
     expect(after?.enabled).toBe(true);
     expect(after?.disabledReason).toBeNull();
     expect(after?.lastError).toBeNull();
+    expect(after?.lastErrorAt).toBeNull();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 407 - 423,
Update the test “does not fire while disabled, and clears its reason when
re-enabled” to seed non-null disabledReason and lastError values on the created
schedule before disabling it. Keep the existing disable, re-enable, and null
assertions so the test verifies the PATCH handler actually clears previously
stored error state.

274-281: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify how the endpoint's createdBy is obtained.

Line 280 creates a schedule through the API, reads its row, and takes createdBy, only to supply a user id for the notification endpoint. The call leaves an extra enabled schedule in the project as a side effect, and the nesting is hard to follow. The admin's user id is available directly.

♻️ Proposed change
+    const [adminUser] = await db
+      .select({ id: users.id })
+      .from(users)
+      .where(eq(users.email, "root@example.com"));
     await db.insert(notificationEndpoints).values({
       projectId,
       kind: "generic",
       name: "hook",
       url: "https://example.com/hook",
       events: [],
-      createdBy: (await rowOf((await createSchedule()).id))?.createdBy as string,
+      createdBy: adminUser?.id as string,
     });

Add users to the @agrippa/db import on lines 3-13.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 274 - 281,
Update the notificationEndpoints insert to obtain createdBy directly from the
admin user record instead of creating a schedule and querying its row. Add the
users symbol to the existing `@agrippa/db` import and use the established admin
user lookup, removing the nested createSchedule/rowOf expression and its extra
schedule side effect.

141-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the audit rows for schedule.update and schedule.delete.

Line 108 verifies the audit row for schedule.create. This test exercises PATCH and DELETE but does not check that either wrote an audit row. The coding guidelines require an audit row for every API mutation, so the two uncovered mutations are exactly the ones a regression would silently drop.

💚 Proposed addition
     expect(unregistered).toContain(deleted.id);
     expect(await rowOf(deleted.id)).toBeUndefined();
+
+    for (const [action, id] of [
+      ["schedule.update", row.id],
+      ["schedule.delete", deleted.id],
+    ] as const) {
+      const [logged] = await db
+        .select()
+        .from(auditLogs)
+        .where(and(eq(auditLogs.action, action), eq(auditLogs.resourceId, id)));
+      expect({ action, logged: logged !== undefined }).toEqual({ action, logged: true });
+    }

As per coding guidelines: "Every API mutation must write an audit row via apps/api/src/lib/audit.ts".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 141 - 155,
Extend the “unregisters the calendar when paused or deleted” test to assert
audit rows for both mutations: verify the PATCH produces a schedule.update audit
entry and the DELETE produces a schedule.delete entry, using the existing
audit-row query/assertion pattern from the schedule.create check near line 108.

Source: Coding guidelines


229-268: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Strengthen the ownership assertion, and cover a finished previous run.

Two gaps.

  1. Line 243 asserts only that run?.createdBy is truthy. The test is named "submits a run attributed to its owner", and docs/design/04-execution-runtime.md line 146 makes owner attribution a design invariant. Compare against the schedule's createdBy so a regression that attributes the run to the wrong user fails the test.

  2. The skip case covers an unfinished previous run. Nothing covers a finished one. That is the normal path: a weekly schedule whose previous run reached a terminal status must fire again. A bug in isTerminalRunStatus handling would make every skip schedule fire exactly once, and this suite would still pass.

💚 Proposed additions
     const [run] = await db
       .select()
       .from(runs)
       .where(eq(runs.id, after?.lastRunId as string));
-    // runs.created_by is NOT NULL — an unattended submission still names a human
-    expect(run?.createdBy).toBeTruthy();
+    // runs.created_by is NOT NULL — an unattended submission still names a human
+    expect(run?.createdBy).toBe(after?.createdBy as string);
   });
+
+  it("fires again once the previous run has finished", async () => {
+    const row = await createSchedule({ concurrencyPolicy: "skip" });
+    await fireSchedule(db, queue, row.id);
+    const first = (await rowOf(row.id))?.lastRunId as string;
+    await db.update(runs).set({ status: "succeeded" }).where(eq(runs.id, first));
+    expect((await fireSchedule(db, queue, row.id)).kind).toBe("submitted");
+    expect((await rowOf(row.id))?.lastRunId).not.toBe(first);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 229 - 268,
Strengthen the ownership assertion in the “submits a run attributed to its
owner” test by comparing run.createdBy with the schedule row’s createdBy, not
merely checking truthiness. Extend the skip-policy coverage in “honors each
concurrency policy against an unfinished previous run” with a terminal previous
run, then verify firing submits a new run and updates lastRunId instead of
skipping.

49-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reset the spy arrays between tests.

registered and unregistered accumulate for the whole file. Line 106 asserts on registered.at(-1), which depends on the create under test being the most recent registration, and every toContain assertion passes for any earlier push. The ids are unique, so the current assertions stay specific, but a test inserted between two others can silently change what at(-1) refers to. A beforeEach that clears both arrays removes the ordering dependency.

♻️ Proposed change
+  beforeEach(() => {
+    registered.length = 0;
+    unregistered.length = 0;
+  });

Import beforeEach from bun:test on line 1, and change line 106 to assert on registered[0].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 49 - 58, Reset
the shared registered and unregistered arrays before each test by importing and
registering beforeEach from bun:test, so each test starts with empty spy state.
In the assertion around registered, use registered[0] rather than
registered.at(-1) after the reset.

175-199: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover the token-in-a-non-string-field case.

The route doc comment in apps/api/src/routes/schedules.ts lines 27-28 states that resolving tokens before validation "also catches a token placed in a field that is not a string". No test exercises that path. A case that puts "{{today}}" into a numeric or boolean input and expects 400 would pin the behavior the comment claims.

Also, the comment on line 219 says the range ends the day before this Monday, but lines 221-224 only assert that the range is ordered and in the past. Either tighten the assertion or reword the comment to match what it checks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 175 - 199,
Extend the schedule integration tests near “rejects an unknown token...” with a
case placing "{{today}}" in a numeric or boolean parameter and assert the POST
returns 400. Update the range test around its existing date-range assertions so
the implementation and comment agree: either assert the range ends the day
before Monday or reword the comment to describe only ordering and pastness.
apps/api/src/routes/schedules.ts (2)

49-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the shared compiled-template lookup.

Lines 49-66 repeat the task-type → template → published version → upgradeCompiledTemplate chain that submitTask performs in packages/orchestration/src/submit.ts (lines 70-88), including the same template_unpublished error. If the resolution rules change, both sites must change together. A shared helper in @agrippa/orchestration (for example loadCompiledTemplateForTaskType(db, taskTypeId)) would keep the contract in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/schedules.ts` around lines 49 - 66, Extract the repeated
task-type → template → published-version resolution and compilation logic from
the schedule route and submitTask into a shared `@agrippa/orchestration` helper,
such as loadCompiledTemplateForTaskType(db, taskTypeId). Preserve the existing
enabled-task-type, template_unpublished, missing-version, and
upgradeCompiledTemplate behaviors, then update both callers to use the helper.

157-193: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Validation and update are not atomic.

Line 159 reads current, then line 172 writes. Two concurrent PATCH requests can each validate against a state the other replaces, so the stored params/timezone pair can differ from the pair that was validated. The window is small and the route is admin-only, so this is a hardening note rather than a defect. Wrapping the read and the write in one transaction closes it, and the same transaction can carry the audit row.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/schedules.ts` around lines 157 - 193, Make the schedule
validation and update in the PATCH handler atomic by wrapping the current-row
read, assertSchedulableParams call, and taskSchedules update in a single
database transaction. Use the transaction handle consistently for all operations
in this flow, preserve the existing not-found behavior, and ensure any audit-row
write associated with the update uses the same transaction.
apps/worker/src/index.ts (1)

285-294: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

One failed registration stops the remaining ones.

Line 290 registers every schedule inside a single try. If registerSchedule rejects for one row, the loop aborts and every schedule after it stays unregistered until the next boot. The warning at line 293 does not name the row that failed. Isolating each registration keeps the rest of the reconciliation useful.

♻️ Proposed change
-  for (const row of enabled) await queue.registerSchedule(row.id, row.cron, row.timezone);
-  deps.logger.info(`reconciled ${enabled.length} schedule(s)`);
+  let ok = 0;
+  for (const row of enabled) {
+    try {
+      await queue.registerSchedule(row.id, row.cron, row.timezone);
+      ok++;
+    } catch (err) {
+      deps.logger.warn("schedule registration failed", { scheduleId: row.id, err: String(err) });
+    }
+  }
+  deps.logger.info(`reconciled ${ok}/${enabled.length} schedule(s)`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/index.ts` around lines 285 - 294, Update the schedule
reconciliation loop around queue.registerSchedule so each row’s registration is
handled independently: catch failures per schedule, log a warning that includes
the affected row.id and error details, and continue registering subsequent rows.
Keep the outer try/catch for query-level reconciliation failures and the final
reconciled count log.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/lib/bearer-tokens.ts`:
- Around line 53-58: Update bearerToken to recognize the Bearer authentication
scheme case insensitively while preserving the existing whitespace trimming and
empty-token behavior. Change only the scheme check in bearerToken; continue
returning the extracted token for valid credentials and null for absent,
unrelated, or empty values.

In `@apps/api/src/routes/execution.ts`:
- Around line 333-336: Update the retry run insertion associated with the
authorization in this route to set createdBy from c.var.principal.userId instead
of c.var.user.id, keeping the row attribution consistent with assertProjectRole
and the authorized principal; leave assertProjectAcceptsWork unchanged.

In `@apps/api/src/routes/schedules.ts`:
- Around line 119-142: Wrap each schedule mutation and its corresponding audit
call in the POST, PATCH, and DELETE handlers using c.var.db.transaction(...).
Pass the transaction object as the third argument to audit(c, { ... }, tx), and
ensure the mutation result and existing validation behavior are preserved within
the transaction callback.

In `@apps/api/src/test/schedules.integration.test.ts`:
- Around line 370-374: Remove the unused orgId alias from the select projection
in the query that assigns anyRun, retaining only runs.id because downstream
logic uses anyRun?.id exclusively.
- Around line 352-360: Wrap the shared task type disable/assertion flow in the
test “disables when its task type is gone” with a try/finally so
taskTypes.enabled is restored even when fireSchedule or its assertion fails.
Apply the same try/finally cleanup to the quota mutation in the quota test,
ensuring the project quota is restored regardless of assertion or execution
failures.

In `@apps/web/src/pages/SettingsPage.tsx`:
- Around line 4-7: Remove the unused SCHEDULE_CONCURRENCY_POLICIES,
ScheduleConcurrencyPolicy, and useQueries imports from SettingsPage.tsx at lines
4-7, and remove the unused TaskTypeSummary import at line 63. Apply Biome
formatting and ensure bun run check passes.
- Around line 1174-1178: Update the clipboard click handler around lastKey.key
to safely handle navigator.clipboard being unavailable and writeText failures,
showing a translated failure toast via settings:apiKeys.copyFailed when copying
cannot complete. Add the apiKeys.copyFailed translation to both English and
zh-CN settings locale resources while preserving existing success behavior.

In `@apps/worker/src/deps/notify.ts`:
- Line 94: Update the reason handling in the notification payload around
ctx.eventPayload.reason to resolve the ScheduleDisabledReason through the
notifications namespace’s scheduleDisabledReasons map instead of passing the raw
enum value. Add matching scheduleDisabledReasons entries for every supported
reason to both the English and zh-CN notifications locale files, and use the
configured locale so rendered messages remain natural.
- Around line 95-97: Update the error formatting expression in the notification
handler around the error variable to branch on its runtime type rather than
relying on the cast. Preserve structured `{ code, message }` formatting for
object errors, while returning the plain string directly when
`ctx.eventPayload.error` is a string so schedule.failed notifications do not
render an empty message.

In `@apps/worker/src/index.ts`:
- Around line 270-278: Update the logging in the queue worker’s schedule-fire
handler after fireSchedule to include the outcome detail alongside outcome.kind:
use outcome.error for failed outcomes and outcome.reason for disabled or skipped
outcomes. Preserve the existing success logging and avoid changing fireSchedule
behavior.

In `@docs/design/04-execution-runtime.md`:
- Line 146: The documentation incorrectly names the nonexistent
assertProjectRole helper. Update the execution-runtime passage to reference
fireSchedule’s actual projectMembers lookup and
projectRoleAtLeast(membership.role, "member") check, or describe that fire-time
membership check without naming a helper.

In `@docs/design/05-api-and-auth.md`:
- Line 13: Update the Governance endpoint list to remove the stale org-level
CRUD /api-keys entry, then add the API-key endpoints under Projects using
POST/GET /api/v1/projects/:projectId/api-keys and POST
/api/v1/projects/:projectId/api-keys/:id/revoke, matching the documented
project-admin surface.

In `@packages/core/src/schemas.ts`:
- Around line 302-304: Update cronField to use superRefine so the submitted cron
value v is directly available when constructing validation issues. Derive the
error message with validateCron(v), preserving the existing generic fallback
while avoiding reliance on issue.input.

In `@packages/i18n/locales/en/notifications.json`:
- Around line 30-37: Update the schedule-disabled notification flow around
schedule-fire and notify so the raw reason identifier is translated via the
settings:schedules.reasons.<reason> key before constructing the notification
payload. Apply this localization for both supported locales, while preserving
the existing scheduleDisabled template and fallback behavior for unknown
reasons.

In `@packages/orchestration/src/notifications.ts`:
- Around line 104-150: Make run-less project notifications idempotent in the
schedule firing flow: update the code around fireSchedule so notifyProjectEvent
runs only after unregisterSchedule succeeds, preventing a retried worker job
from creating duplicate deliveries. Preserve the existing error propagation and
transition behavior, and do not rely on the event_id-based
notification_deliveries_dedupe_uq for this run-less path.

In `@packages/orchestration/src/schedule-fire.ts`:
- Line 54: Update the schedule checks in the relevant flow, including the guard
near line 54 and the corresponding check near line 100, to use optional chaining
as required by Biome’s useOptionalChain rule while preserving their existing
behavior and return values.
- Around line 56-79: Update the disable function so the enabled=false state
change and auditAs record are committed atomically in one database transaction.
Keep notifyProjectEvent and queue?.unregisterSchedule as best-effort operations
inside a try/catch after the transaction, ensuring their failures do not
propagate from fireSchedule while preserving the disabled outcome.
- Around line 181-193: Update the catch in fireSchedule so only AppError and the
intended recoverable SubmitError classification are converted through
fail(message); rethrow all other errors, including plain infrastructure Error
instances from submitTask, so the scheduled worker job can retry. Preserve the
existing AppError message formatting and verify SubmitError is included in the
recoverable submit-path classification.

---

Nitpick comments:
In `@apps/api/src/lib/bearer-tokens.ts`:
- Around line 47-51: Update issueToken to validate that the supplied prefix is
shorter than DISPLAY_PREFIX_LENGTH before concatenating and deriving
tokenPrefix. Throw an invariant error at the call site when the prefix is too
long, while preserving the existing token generation and hashing behavior for
valid prefixes.

In `@apps/api/src/middleware/auth.ts`:
- Around line 52-58: Update the API-key denial branches in the authentication
middleware to use distinct stable error codes for endpoint-unavailable and
missing-scope cases. Move the required scope from the interpolated message into
the error details so each message can localize solely through its code, and add
matching English and zh-CN entries in the errors namespace for both codes.

In `@apps/api/src/routes/api-keys.ts`:
- Around line 57-78: In the API key creation handler, add an explicit guard
immediately after the insert and before audit creation, using the returned row
from the insert operation. Throw the same established error used by the sibling
schedule route when no row is returned, and preserve the existing audit and 201
response flow for successful inserts.

In `@apps/api/src/routes/catalog.ts`:
- Line 3: Reuse the centralized DEFAULT_EXECUTOR from `@agrippa/orchestration`
instead of redefining it locally. In apps/api/src/routes/catalog.ts at lines
3-3, import DEFAULT_EXECUTOR and remove the local declaration at line 8; in
apps/api/src/routes/projects.ts at lines 38-43, add the same import and remove
the local defaultExecutor declaration at line 638, updating references as
needed.

In `@apps/api/src/routes/schedules.ts`:
- Around line 49-66: Extract the repeated task-type → template →
published-version resolution and compilation logic from the schedule route and
submitTask into a shared `@agrippa/orchestration` helper, such as
loadCompiledTemplateForTaskType(db, taskTypeId). Preserve the existing
enabled-task-type, template_unpublished, missing-version, and
upgradeCompiledTemplate behaviors, then update both callers to use the helper.
- Around line 157-193: Make the schedule validation and update in the PATCH
handler atomic by wrapping the current-row read, assertSchedulableParams call,
and taskSchedules update in a single database transaction. Use the transaction
handle consistently for all operations in this flow, preserve the existing
not-found behavior, and ensure any audit-row write associated with the update
uses the same transaction.

In `@apps/api/src/test/api-keys.integration.test.ts`:
- Around line 255-270: Update the test “stops working when its owner loses
project membership” to avoid permanently deleting the shared project_members
row: either create and use a dedicated project for this scenario or restore the
owner’s membership after asserting the 403 response. Keep the membership state
required by other tests intact regardless of test execution order.

In `@apps/api/src/test/api.integration.test.ts`:
- Around line 194-206: Extend the archived-project scenario around the existing
submit assertion to call POST /tasks/:id/retry for the pre-existing task, then
assert a 409 response with error code project_archived. Reuse the existing
project and task identifiers and request helpers so retry coverage verifies the
assertProjectAcceptsWork guard without changing the submission or history
checks.

In `@apps/api/src/test/schedules.integration.test.ts`:
- Around line 407-423: Update the test “does not fire while disabled, and clears
its reason when re-enabled” to seed non-null disabledReason and lastError values
on the created schedule before disabling it. Keep the existing disable,
re-enable, and null assertions so the test verifies the PATCH handler actually
clears previously stored error state.
- Around line 274-281: Update the notificationEndpoints insert to obtain
createdBy directly from the admin user record instead of creating a schedule and
querying its row. Add the users symbol to the existing `@agrippa/db` import and
use the established admin user lookup, removing the nested createSchedule/rowOf
expression and its extra schedule side effect.
- Around line 141-155: Extend the “unregisters the calendar when paused or
deleted” test to assert audit rows for both mutations: verify the PATCH produces
a schedule.update audit entry and the DELETE produces a schedule.delete entry,
using the existing audit-row query/assertion pattern from the schedule.create
check near line 108.
- Around line 229-268: Strengthen the ownership assertion in the “submits a run
attributed to its owner” test by comparing run.createdBy with the schedule row’s
createdBy, not merely checking truthiness. Extend the skip-policy coverage in
“honors each concurrency policy against an unfinished previous run” with a
terminal previous run, then verify firing submits a new run and updates
lastRunId instead of skipping.
- Around line 49-58: Reset the shared registered and unregistered arrays before
each test by importing and registering beforeEach from bun:test, so each test
starts with empty spy state. In the assertion around registered, use
registered[0] rather than registered.at(-1) after the reset.
- Around line 175-199: Extend the schedule integration tests near “rejects an
unknown token...” with a case placing "{{today}}" in a numeric or boolean
parameter and assert the POST returns 400. Update the range test around its
existing date-range assertions so the implementation and comment agree: either
assert the range ends the day before Monday or reword the comment to describe
only ordering and pastness.

In `@apps/web/src/lib/types.ts`:
- Line 377: Update the type containing concurrencyPolicy in types.ts to use the
shared ScheduleConcurrencyPolicy imported from `@agrippa/core` instead of the
duplicated string union, preserving the existing property name and contract.

In `@apps/web/src/pages/SettingsPage.tsx`:
- Around line 1290-1294: Update the Switch in the schedule row to include the
specific schedule’s identifying name in its accessible label, rather than using
the shared settings:schedules.toggle translation alone. Preserve the existing
checked state and update.mutate behavior while ensuring each row’s control is
distinguishable to screen readers.

In `@apps/worker/src/index.ts`:
- Around line 285-294: Update the schedule reconciliation loop around
queue.registerSchedule so each row’s registration is handled independently:
catch failures per schedule, log a warning that includes the affected row.id and
error details, and continue registering subsequent rows. Keep the outer
try/catch for query-level reconciliation failures and the final reconciled count
log.

In `@docs/design/04-execution-runtime.md`:
- Around line 142-147: Move the three scheduled-submission paragraphs out from
under “### Notifications (outbound)” into a new “### Scheduled submission”
section near the existing schedule.fire bullet, preserving their content and
order. In the token-validation paragraph, explicitly state that both schedule
creation and PATCH edits reject unknown tokens and validate resolved parameters
against the compiled schema.

In `@packages/core/src/schedules.test.ts`:
- Around line 153-162: Add a parity test in schedules.test.ts that imports
SCHEDULE_TOKENS and verifies every token classified as known by
findUnknownScheduleTokens has a corresponding key in scheduleTokenValues used by
applyScheduleTokens. Keep the assertion focused on detecting drift between these
two token sets.

In `@packages/orchestration/src/submit.ts`:
- Around line 106-118: Update submitTask and the project verification flow
around assertProjectAcceptsWork to retain the verified project row, then set the
tasks insert’s orgId from that row’s projects.orgId instead of actor.orgId.
Preserve the existing project-role and work-acceptance checks while ensuring
downstream org-scoped records use the target project’s organization.

In `@packages/orchestration/src/usage.ts`:
- Around line 95-109: Update assertQuotaHeadroom to query the current month’s
token total directly instead of calling projectUsage, preserving the existing
quota limit comparison and error behavior. Reuse the existing periodTokens
helper or equivalent total-query symbol, and update projectUsage to obtain its
tokens field through periodTokens while retaining its other display metrics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 750df31b-f068-41e5-877b-fc2fffedf472

📥 Commits

Reviewing files that changed from the base of the PR and between 8b4ebb7 and bd84369.

📒 Files selected for processing (66)
  • CHANGELOG.md
  • apps/api/src/app.ts
  • apps/api/src/context.ts
  • apps/api/src/lib/api-key-routes.ts
  • apps/api/src/lib/audit.ts
  • apps/api/src/lib/bearer-tokens.ts
  • apps/api/src/lib/runtime-tokens.ts
  • apps/api/src/middleware/auth.ts
  • apps/api/src/middleware/rbac.ts
  • apps/api/src/routes/api-keys.ts
  • apps/api/src/routes/catalog.ts
  • apps/api/src/routes/execution.ts
  • apps/api/src/routes/invitations.ts
  • apps/api/src/routes/projects.ts
  • apps/api/src/routes/schedules.ts
  • apps/api/src/test/api-keys.integration.test.ts
  • apps/api/src/test/api.integration.test.ts
  • apps/api/src/test/checkpoints.integration.test.ts
  • apps/api/src/test/execution.integration.test.ts
  • apps/api/src/test/fleet.integration.test.ts
  • apps/api/src/test/helpers.ts
  • apps/api/src/test/notifications.integration.test.ts
  • apps/api/src/test/runtimes.integration.test.ts
  • apps/api/src/test/schedules.integration.test.ts
  • apps/web/src/lib/types.ts
  • apps/web/src/pages/SettingsPage.tsx
  • apps/web/src/pages/SubmitTaskPage.tsx
  • apps/worker/src/deps/notify.ts
  • apps/worker/src/index.ts
  • docs/design/04-execution-runtime.md
  • docs/design/05-api-and-auth.md
  • docs/manual/en/04-administration.md
  • docs/manual/zh-CN/04-administration.md
  • packages/core/src/api-keys.ts
  • packages/core/src/index.ts
  • packages/core/src/notifications.ts
  • packages/core/src/queue.ts
  • packages/core/src/schedules.test.ts
  • packages/core/src/schedules.ts
  • packages/core/src/schemas.ts
  • packages/db/drizzle/0023_api_keys_prefix_uq.sql
  • packages/db/drizzle/0024_task_schedules.sql
  • packages/db/drizzle/meta/0023_snapshot.json
  • packages/db/drizzle/meta/0024_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/api-keys.ts
  • packages/db/src/schema/index.ts
  • packages/db/src/schema/schedules.ts
  • packages/i18n/locales/en/catalog.json
  • packages/i18n/locales/en/errors.json
  • packages/i18n/locales/en/notifications.json
  • packages/i18n/locales/en/settings.json
  • packages/i18n/locales/zh-CN/catalog.json
  • packages/i18n/locales/zh-CN/errors.json
  • packages/i18n/locales/zh-CN/notifications.json
  • packages/i18n/locales/zh-CN/settings.json
  • packages/orchestration/src/audit.ts
  • packages/orchestration/src/engine/run-lifecycle.ts
  • packages/orchestration/src/index.ts
  • packages/orchestration/src/notifications.ts
  • packages/orchestration/src/queue.ts
  • packages/orchestration/src/run-plan.ts
  • packages/orchestration/src/schedule-fire.ts
  • packages/orchestration/src/submit.ts
  • packages/orchestration/src/usage.ts
  • packages/orchestration/src/worker-executors.ts

Comment thread apps/api/src/lib/bearer-tokens.ts Outdated
Comment on lines +333 to +336
await assertProjectRole(db, c.var.principal, task.projectId, "member");
// a retry is new work and consumes tokens like any run — an archived
// project refuses it for the same reason it refuses a submission
await assertProjectAcceptsWork(db, task.projectId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Retry authorization now uses the principal, but the new run row is still attributed to c.var.user.id.

Line 333 authorizes with c.var.principal. The run insert at line 407 sets createdBy: c.var.user.id. This is the same identity mismatch flagged on the submit route. Use c.var.principal.userId for createdBy so the row attribution matches the authorized principal.

The added assertProjectAcceptsWork call is correct and matches the submit path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/execution.ts` around lines 333 - 336, Update the retry
run insertion associated with the authorization in this route to set createdBy
from c.var.principal.userId instead of c.var.user.id, keeping the row
attribution consistent with assertProjectRole and the authorized principal;
leave assertProjectAcceptsWork unchanged.

Comment thread apps/api/src/routes/schedules.ts Outdated
Comment on lines +352 to +360
it("disables when its task type is gone", async () => {
const row = await createSchedule();
await db.update(taskTypes).set({ enabled: false }).where(eq(taskTypes.id, taskTypeId));
expect(await fireSchedule(db, queue, row.id)).toEqual({
kind: "disabled",
reason: "task_type_gone",
});
await db.update(taskTypes).set({ enabled: true }).where(eq(taskTypes.id, taskTypeId));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore the shared task type in a finally block.

Line 354 disables the taskTypes row that every other test in this file uses, and line 359 restores it in the test body. If the assertion on lines 355-358 fails, the restore never runs. The next test at line 362 then fires a schedule against a disabled task type, so it receives { kind: "disabled", reason: "task_type_gone" } instead of the expected failed outcome, and the quota assertions fail for an unrelated reason. One real failure becomes a cascade that hides its own cause.

💚 Proposed fix
   it("disables when its task type is gone", async () => {
     const row = await createSchedule();
     await db.update(taskTypes).set({ enabled: false }).where(eq(taskTypes.id, taskTypeId));
-    expect(await fireSchedule(db, queue, row.id)).toEqual({
-      kind: "disabled",
-      reason: "task_type_gone",
-    });
-    await db.update(taskTypes).set({ enabled: true }).where(eq(taskTypes.id, taskTypeId));
+    try {
+      expect(await fireSchedule(db, queue, row.id)).toEqual({
+        kind: "disabled",
+        reason: "task_type_gone",
+      });
+    } finally {
+      await db.update(taskTypes).set({ enabled: true }).where(eq(taskTypes.id, taskTypeId));
+    }
   });

The quota test at lines 362-405 has the same shape: it restores the project quota on line 401 in the test body. Apply the same treatment there.

📝 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
it("disables when its task type is gone", async () => {
const row = await createSchedule();
await db.update(taskTypes).set({ enabled: false }).where(eq(taskTypes.id, taskTypeId));
expect(await fireSchedule(db, queue, row.id)).toEqual({
kind: "disabled",
reason: "task_type_gone",
});
await db.update(taskTypes).set({ enabled: true }).where(eq(taskTypes.id, taskTypeId));
});
it("disables when its task type is gone", async () => {
const row = await createSchedule();
await db.update(taskTypes).set({ enabled: false }).where(eq(taskTypes.id, taskTypeId));
try {
expect(await fireSchedule(db, queue, row.id)).toEqual({
kind: "disabled",
reason: "task_type_gone",
});
} finally {
await db.update(taskTypes).set({ enabled: true }).where(eq(taskTypes.id, taskTypeId));
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 352 - 360, Wrap
the shared task type disable/assertion flow in the test “disables when its task
type is gone” with a try/finally so taskTypes.enabled is restored even when
fireSchedule or its assertion fails. Apply the same try/finally cleanup to the
quota mutation in the quota test, ensuring the project quota is restored
regardless of assertion or execution failures.

Comment on lines +370 to +374
const [anyRun] = await db
.select({ id: runs.id, orgId: runs.projectId })
.from(runs)
.where(eq(runs.projectId, projectId))
.limit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the mislabeled orgId alias.

Line 371 selects orgId: runs.projectId. The alias names an org id but reads a project id. Only anyRun?.id is used on line 382, so nothing breaks, but the field misstates what it holds and invites a wrong copy.

♻️ Proposed change
     const [anyRun] = await db
-      .select({ id: runs.id, orgId: runs.projectId })
+      .select({ id: runs.id })
       .from(runs)
       .where(eq(runs.projectId, projectId))
       .limit(1);
📝 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
const [anyRun] = await db
.select({ id: runs.id, orgId: runs.projectId })
.from(runs)
.where(eq(runs.projectId, projectId))
.limit(1);
const [anyRun] = await db
.select({ id: runs.id })
.from(runs)
.where(eq(runs.projectId, projectId))
.limit(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 370 - 374,
Remove the unused orgId alias from the select projection in the query that
assigns anyRun, retaining only runs.id because downstream logic uses anyRun?.id
exclusively.

Comment thread packages/i18n/locales/en/notifications.json
Comment thread packages/orchestration/src/notifications.ts
scheduleId: string,
): Promise<ScheduleFireOutcome> {
const [schedule] = await db.select().from(taskSchedules).where(eq(taskSchedules.id, scheduleId));
if (!schedule || !schedule.enabled) return { kind: "skipped", reason: "disabled" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Apply the Biome useOptionalChain fix at lines 54 and 100.

CI reports lint/complexity/useOptionalChain at both lines. The coding guidelines require bun run check to pass.

🧹 Proposed fix
-  if (!schedule || !schedule.enabled) return { kind: "skipped", reason: "disabled" };
+  if (!schedule?.enabled) return { kind: "skipped", reason: "disabled" };
-  if (!project || project.status !== "active") return disable("project_archived");
+  if (project?.status !== "active") return disable("project_archived");

As per coding guidelines: "Use Biome formatting and linting, and keep bun run check passing".

🧰 Tools
🪛 GitHub Check: ci

[warning] 54-54: lint/complexity/useOptionalChain
Change to an optional chain.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/schedule-fire.ts` at line 54, Update the schedule
checks in the relevant flow, including the guard near line 54 and the
corresponding check near line 100, to use optional chaining as required by
Biome’s useOptionalChain rule while preserving their existing behavior and
return values.

Sources: Coding guidelines, Linters/SAST tools

Comment thread packages/orchestration/src/schedule-fire.ts
Comment thread packages/orchestration/src/schedule-fire.ts
A signed URL an external system POSTs to in order to start a task. Track N's
outbound pipeline inverted — a token plus a signing secret, a durable delivery
log with payload inspection, replay — with the HMAC verified rather than
generated, using deliberately the same x-agrippa-timestamp / x-agrippa-signature
convention the platform sends. One dialect in both directions beats two.

Three decisions worth recording.

The token travels in the URL path, not a header, because the senders that
matter — CI runners, IM bots, hosted git providers — can all POST to a URL and
many cannot set Authorization. That makes the URL a capability, which is
exactly why the signing secret is *mandatory* here although it is optional on
outbound IM endpoints: an unsigned inbound trigger is an open "spend this
project's tokens" endpoint for anyone who ever sees the URL in a proxy log or a
CI transcript. The signature covers the raw request bytes, never a
re-serialization, and timestamps outside ±5 minutes are refused in both
directions so a fast sender clock cannot buy a longer replay window.

The request records and acknowledges; the worker submits. The delivery row is
written before the 202, so a submission that fails later stays visible and
replayable instead of vanishing with the sender's connection, and a slow submit
can never become a timeout the sender retries into a duplicate run. An optional
x-agrippa-delivery-id makes that idempotent outright via a partial unique
index, and fireTrigger refuses a delivery already marked succeeded — so
at-least-once job delivery and an operator hitting replay converge on one run.

Because the sender is acknowledged before the run exists, it can never learn
that the run did not happen. trigger.disabled and trigger.failed notifications
are therefore not a nicety but the only channel that reaches a human — the same
argument that made schedules disable loudly rather than skip. Both features now
share checkWorkAuthority for the three permanent conditions, since both needed
exactly that check at exactly the same moment.

The received payload is stored for inspection and never interpolated into a
prompt: a valid signature proves who sent the bytes, not that the bytes are
safe. Mapping payload fields into task parameters is deliberately out of scope
— it is a prompt-injection surface that deserves its own design.

One bug the browser smoke caught that the unit tests did not: creation
validates the *resolved* parameters (sharing the schedule validator), so it
accepted a {{lastWeekStart}} token — but firing passed endpoint.params
straight through, and the token reached the agent's prompt verbatim. Triggers
now carry a timezone and resolve tokens at fire time exactly as schedules do,
because the same token typed into the same form field has to mean the same
thing in both places. Covered by a regression test asserting the run's snapshot
holds dates while the stored parameter keeps the token.

Verified end to end against a live stack, not only in tests: created a trigger
through the browser, POSTed a correctly signed request (202) plus a
wrong-signature and a stale-timestamp one (both the same uniform 401), repeated
the request with the same delivery id (deduplicated, still one delivery row),
and confirmed the worker produced a run whose dateRange resolved to
2026-07-27..2026-08-02. 14 integration tests cover the rest.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/orchestration/src/schedule-fire.ts (1)

135-161: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

A failed bookkeeping update after a successful submit is misreported as a submission failure.

The try block wraps both submitTask (line 136) and the follow-up db.update(taskSchedules) call (lines 151-160). If submitTask succeeds but the update throws, the catch at line 162 treats it the same as a submit failure: it calls fail(), which writes a misleading lastError and emits schedule.failed, even though a task and run were already created and enqueued. lastRunId/lastFiredAt stay stale, so the overlap check at lines 105-127 evaluates the wrong prior run on the next firing.

Separate the bookkeeping update from the submission call so an update failure does not overwrite a genuine "submitted" outcome.

🛠️ Proposed fix
   try {
-    const { taskId, runId } = await submitTask(db, queue, {
+    var submitResult;
+    submitResult = await submitTask(db, queue, {
       projectId: schedule.projectId,
       actorUserId: schedule.createdBy,
       actor: { orgId: schedule.orgId, userId: schedule.createdBy },
       input: {
         taskTypeId: schedule.taskTypeId,
         title: schedule.name,
         params: applyScheduleTokens(schedule.params, firedAt, schedule.timezone),
         agents: schedule.agentOverrides,
       },
     });
-    await db
-      .update(taskSchedules)
-      .set({
-        lastFiredAt: firedAt,
-        lastRunId: runId,
-        lastError: null,
-        lastErrorAt: null,
-        updatedAt: new Date(),
-      })
-      .where(eq(taskSchedules.id, scheduleId));
-    return { kind: "submitted", taskId, runId };
   } catch (err) {
     const message =
       err instanceof AppError
         ? `${err.code}: ${err.message}`
         : err instanceof Error
           ? err.message
           : String(err);
     return fail(message);
   }
+  // bookkeeping happens outside the submit try/catch: a failure here must
+  // never be reported as a submission failure, since the run already exists
+  await db
+    .update(taskSchedules)
+    .set({
+      lastFiredAt: firedAt,
+      lastRunId: submitResult.runId,
+      lastError: null,
+      lastErrorAt: null,
+      updatedAt: new Date(),
+    })
+    .where(eq(taskSchedules.id, scheduleId));
+  return { kind: "submitted", taskId: submitResult.taskId, runId: submitResult.runId };

Note this restructuring lets the bookkeeping-update exception propagate out of fireSchedule on failure; confirm that is acceptable given submitTask already committed, or wrap it in its own try/catch that logs rather than reclassifies the outcome, since a plain rethrow here would let pg-boss retry the whole firing and resubmit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/schedule-fire.ts` around lines 135 - 161, Separate
submitTask from the taskSchedules bookkeeping update in fireSchedule: only
submission errors should invoke fail() and produce a failed outcome. After a
successful submission, update lastFiredAt, lastRunId, and related fields in a
distinct error-handling path that logs bookkeeping failures without
reclassifying or retrying the already-submitted task; preserve the submitted
result.
♻️ Duplicate comments (4)
packages/orchestration/src/schedule-fire.ts (2)

162-174: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Plain Errors from submitTask are still recorded as recoverable schedule failures instead of propagating for a pg-boss retry.

Every thrown value that is not an AppError is converted into a fail() outcome via err.message. An infrastructure-level Error (a DB error inside submitTask, a queue-name derivation throw) is indistinguishable here from a legitimate configuration problem like usage_limit_exceeded. Because fireSchedule returns normally after fail(), pg-boss does not retry the underlying job for an infrastructure fault.

This is the same finding raised on a prior revision of this file and it is still present unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/schedule-fire.ts` around lines 162 - 174, Update
the catch handling in fireSchedule so only AppError instances are converted to
the recoverable fail(message) outcome; rethrow plain Error and other unexpected
thrown values so pg-boss can retry infrastructure failures from submitTask or
queue-name derivation.

56-79: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

disable can fail silently between the state flip and the announcement.

disable writes enabled = false first, then the audit row, then the notification, then unregisterSchedule. If any of the latter three throws, the exception propagates out of fireSchedule. On the pg-boss retry, line 54's !schedule?.enabled check returns skipped immediately, so the audit row, the schedule.disabled notification, and the calendar unregistration never happen. The schedule stops firing with no record of why.

This is the same finding raised on a prior revision of this file and it is still present unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/schedule-fire.ts` around lines 56 - 79, Update the
disable function so marking the schedule disabled and completing its audit,
notification, and queue unregistration are resilient to failures after the
database state change. Ensure those follow-up actions are retried or otherwise
completed independently of the early !schedule?.enabled path, while preserving
the disabled outcome and reason.
docs/design/04-execution-runtime.md (1)

147-147: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc still names a helper that does not exist in the implementation.

The text states the handler applies assertProjectRole(owner, project, "member"). The current implementation calls checkWorkAuthority (packages/orchestration/src/schedule-fire.ts lines 96-101, packages/orchestration/src/authority.ts lines 27-52), which has no function named assertProjectRole. A reader who greps for assertProjectRole finds nothing in the schedule path.

Name the helper that is actually used, or describe the fire-time membership check without naming a specific symbol.

This is the same finding raised on a prior revision of this file and it is still present unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/04-execution-runtime.md` at line 147, Update the schedule
authority paragraph to reference the implemented checkWorkAuthority helper
instead of assertProjectRole, or describe the fire-time membership validation
without naming a helper. Keep the existing explanation of owner-based
authorization and attribution unchanged.
apps/worker/src/index.ts (1)

283-291: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Worker job consumers drop failure detail from structured logs. fireSchedule and fireTrigger both return outcome unions that carry error (for failed) or reason (for disabled/skipped) alongside kind, but both consumers log only outcome.kind. This is the shared root cause behind both sites; a schedule or trigger that goes silent in production shows no reason in the worker log.

  • apps/worker/src/index.ts#L283-L291: include outcome.error/outcome.reason alongside outcome.kind when logging the trigger delivery outcome.
  • apps/worker/src/index.ts#L273-L281: include outcome.error/outcome.reason alongside outcome.kind when logging the schedule-fire outcome, as requested on a prior revision.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/index.ts` around lines 283 - 291, Update both the
schedule-fire consumer at apps/worker/src/index.ts lines 273-281 and the trigger
delivery consumer at apps/worker/src/index.ts lines 283-291 to include the
outcome’s error or reason alongside outcome.kind in their structured logger
calls. Preserve the existing outcome handling and use the appropriate detail for
failed, disabled, and skipped outcomes.
🧹 Nitpick comments (10)
packages/orchestration/src/authority.ts (1)

15-15: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Derive WorkAuthorityFailure from the shared reason list.

WorkAuthorityFailure lists the same three values as TRIGGER_DISABLED_REASONS in packages/core/src/triggers.ts. The two declarations are independent. fireTrigger writes the result of checkWorkAuthority straight into trigger_endpoints.disabled_reason, so if one list gains a member and the other does not, the persisted value falls outside the vocabulary the API and SPA expect, and no compiler error reports it.

Declare one union and derive the other, or export a single shared list from @agrippa/core.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/authority.ts` at line 15, Update
WorkAuthorityFailure in authority.ts to derive its union from the shared
TRIGGER_DISABLED_REASONS definition in `@agrippa/core`, or centralize both
declarations on one exported shared list. Ensure checkWorkAuthority and
trigger_endpoints.disabled_reason use the same source of truth so any future
reason additions are compiler-checked.
packages/core/src/schemas.ts (1)

358-369: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add secret rotation to the trigger update contract.

triggerUpdateSchema omits secret. The PATCH handler in apps/api/src/routes/triggers.ts therefore never writes secretRef. If a signing secret leaks, an admin cannot rotate it. The only remedy is delete plus recreate, which also invalidates the URL token and forces every sender to be reconfigured.

Consider adding an optional secret field with the same length bounds as triggerCreateSchema, and re-encrypt the stored secret when it is present.

♻️ Proposed contract change
 export const triggerUpdateSchema = z
   .object({
     name: z.string().min(1).max(200),
     timezone: timezoneField,
     params: z.record(z.string(), z.unknown()),
     agents: z.record(
       z.string(),
       z.object({ executorId: z.string().min(1).optional(), faberId: z.uuid().optional() }),
     ),
     enabled: z.boolean(),
+    /** Write-only; rotates the stored signing secret. */
+    secret: z.string().min(16).max(200),
   })
   .partial();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/schemas.ts` around lines 358 - 369, Extend
triggerUpdateSchema with an optional secret field using the same validation and
length bounds as triggerCreateSchema, then update the PATCH handler to detect a
provided secret, re-encrypt it, and persist the resulting secretRef while
preserving the existing secret when omitted.
packages/db/src/schema/triggers.ts (1)

72-72: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Type disabledReason with the shared vocabulary.

status on triggerDeliveries narrows its values through text("status", { enum: [...] }), but disabledReason stays untyped text. packages/core/src/triggers.ts already exports TriggerDisabledReason as a closed set of three values. Narrowing the column makes the writer in packages/orchestration/src/trigger-fire.ts type-checked against that set, so the column and the exported union cannot drift apart.

♻️ Proposed typing change
-    disabledReason: text("disabled_reason"),
+    disabledReason: text("disabled_reason").$type<TriggerDisabledReason>(),

Add the import:

+import type { TriggerDisabledReason } from "`@agrippa/core`";

Note: confirm the dependency direction is allowed before adding this import. If packages/db must not import @agrippa/core, keep the column untyped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/schema/triggers.ts` at line 72, Update the disabledReason
column in the triggerDeliveries schema to use the shared TriggerDisabledReason
vocabulary from packages/core, matching the enum-style typing used by status.
First verify that packages/db may depend on `@agrippa/core`; if that dependency is
disallowed, leave disabledReason untyped rather than introducing an invalid
import.
apps/web/src/pages/SettingsPage.tsx (2)

1077-1080: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider polling trigger deliveries.

A delivery row starts in pending and the worker updates it asynchronously. This query has no refetchInterval, so the list stays stale until the user remounts the section. NotificationsSection polls its deliveries every 10 seconds at line 806.

♻️ Proposed change
   const deliveries = useQuery({
     queryKey: ["trigger-deliveries", projectId],
     queryFn: () => api<TriggerDeliveryRow[]>(`/projects/${projectId}/triggers/deliveries?limit=30`),
+    refetchInterval: 10_000,
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/SettingsPage.tsx` around lines 1077 - 1080, Update the
trigger deliveries useQuery in SettingsPage to poll for refreshed results by
adding a 10-second refetch interval, matching NotificationsSection’s existing
delivery polling behavior while preserving the current query key and query
function.

1236-1236: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Type the default scopes against API_KEY_SCOPES.

The default scope list uses bare strings. If a scope identifier in @agrippa/core changes, the default no longer matches the allow-list and the API rejects the create request. The compiler cannot detect this.

♻️ Proposed change
-  const [scopes, setScopes] = useState<string[]>(["tasks:write", "runs:read"]);
+  type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
+  const [scopes, setScopes] = useState<ApiKeyScope[]>(["tasks:write", "runs:read"]);

Adjust toggleScope to accept ApiKeyScope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/SettingsPage.tsx` at line 1236, Type the default scopes
and toggleScope state updates against the API_KEY_SCOPES allow-list by using the
ApiKeyScope type instead of bare string identifiers. Update toggleScope to
accept ApiKeyScope so renamed or removed scope values are caught by the compiler
while preserving the existing defaults.
apps/web/src/pages/SubmitTaskPage.tsx (1)

277-282: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Mask the signing secret input.

This field holds a shared HMAC secret, but it renders as plain text. The notification secret field in apps/web/src/pages/SettingsPage.tsx uses type="password" with autoComplete="off". Match that behavior.

♻️ Proposed change
               <Input
                 id="trigger-secret"
+                type="password"
+                autoComplete="off"
                 value={secret}
                 onChange={(e) => setSecret(e.target.value)}
                 placeholder={t("catalog:trigger.secretPlaceholder")}
               />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/SubmitTaskPage.tsx` around lines 277 - 282, Update the
signing secret Input identified by id "trigger-secret" in SubmitTaskPage to use
password masking and disable browser autocomplete, matching the notification
secret field’s behavior in SettingsPage while preserving its existing value and
change handling.
apps/api/src/test/triggers.integration.test.ts (2)

103-107: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the seeded task type in beforeAll.

Line 106 casts a possibly missing id with as string. If the weekly-report seed changes, taskTypeId becomes undefined and every later test fails with an unrelated validation error. Add expect(taskTypeId).toBeTruthy() so the setup failure is reported at its source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/triggers.integration.test.ts` around lines 103 - 107, Add
an explicit expect(taskTypeId).toBeTruthy() assertion in the beforeAll setup
immediately after locating the "weekly-report" task type, before later tests use
taskTypeId, so missing seed data fails during setup rather than through
unrelated validation errors.

251-255: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the oversized body from TRIGGER_PAYLOAD_MAX_BYTES.

The literal 70_000 assumes the current cap. If the constant is raised, this test no longer exceeds the limit and the 413 assertion fails for an unrelated reason. Import the constant and size the body from it.

♻️ Proposed change
-    const res = await send(t.token as string, { blob: "x".repeat(70_000) });
+    const res = await send(t.token as string, {
+      blob: "x".repeat(TRIGGER_PAYLOAD_MAX_BYTES + 1_000),
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/triggers.integration.test.ts` around lines 251 - 255,
Update the “caps the payload size” test to import and use
TRIGGER_PAYLOAD_MAX_BYTES, constructing a payload larger than that configured
limit instead of relying on the hardcoded 70_000 value while preserving the
expected 413 response.
packages/orchestration/src/trigger-fire.ts (1)

77-99: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Write the disable mutation and its audit row in one transaction.

Lines 78-93 disable the endpoint, mark the attempt, and then audit, each as a separate statement. If the worker crashes between them, the endpoint stays disabled with no audit row. submitTask in packages/orchestration/src/submit.ts keeps auditAs inside the mutation transaction for this reason. Wrap the endpoint update, the attempt mark, and auditAs in a single db.transaction, and keep notifyProjectEvent after the commit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/trigger-fire.ts` around lines 77 - 99, Update the
denied branch in the trigger-fire flow to wrap the triggerEndpoints update,
markAttempt, and auditAs calls in a single db.transaction, preserving their
current operations and data. Keep notifyProjectEvent outside and after the
transaction commits, then return the existing disabled result.
apps/api/src/routes/trigger-inbound.ts (1)

87-96: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Normalize array payloads instead of casting with as never.

typeof [] === "object", so a JSON array is stored in payload, which the schema types as Record<string, unknown>. The as never cast hides that mismatch from the type checker. Wrap non-object JSON values explicitly.

♻️ Proposed normalization
-      payload =
-        parsed !== null && typeof parsed === "object" ? (parsed as never) : { value: parsed };
+      payload =
+        parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
+          ? (parsed as Record<string, unknown>)
+          : { value: parsed };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/trigger-inbound.ts` around lines 87 - 96, Update payload
normalization in the raw JSON parsing block to distinguish plain object payloads
from arrays before assigning to the Record<string, unknown> payload. Remove the
`as never` cast, preserve scalar wrapping via `{ value: parsed }`, and
explicitly normalize array values into an object-compatible representation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/trigger-inbound.ts`:
- Around line 60-65: Update the inbound trigger handler around the raw-body read
to reject oversized requests before calling c.req.text(), using the request
Content-Length against TRIGGER_PAYLOAD_MAX_BYTES, and apply Hono bodyLimit
middleware to stop oversized streams during reading. Preserve the existing
exact-byte signature verification and AppError response for payloads that exceed
the configured limit.

In `@apps/api/src/routes/triggers.ts`:
- Around line 177-199: Update the limit calculation in the deliveries route
handler to convert invalid, zero, and negative query values to the default
positive limit, while capping valid values at 100. Ensure the value passed to
the Drizzle .limit call is always a finite positive number.
- Around line 157-173: Update the delete trigger handler around the returning
clause and audit flow to also return secretRef, then delete the corresponding
row from secrets in the same database transaction as the trigger endpoint
deletion. Preserve the existing not-found behavior and audit payload, and ensure
cleanup occurs only after a trigger row is successfully deleted.

In `@apps/api/src/test/triggers.integration.test.ts`:
- Around line 132-140: Add member to the project with the member role before the
request in the “is project-admin gated and rejects params that could never run”
test. Keep the existing request and 403 assertion so the test verifies an
authenticated project member is denied by the admin gate, not merely rejected
for lacking membership.

In `@apps/web/src/pages/SubmitTaskPage.tsx`:
- Around line 251-254: Update the clipboard handlers in
apps/web/src/pages/SubmitTaskPage.tsx lines 251-254 and
apps/web/src/pages/SettingsPage.tsx lines 1335-1339 to guard navigator.clipboard
before calling writeText, handle synchronous access failures, and catch rejected
promises with localized failure toasts for the trigger URL and plaintext API key
respectively. Add matching failure-message translation keys to the relevant en
and zh-CN JSON locale files while preserving locale key parity.

In `@docs/manual/zh-CN/04-administration.md`:
- Line 55: Update the intercepted-request sentence in the administration
documentation to explicitly state that even if a request is intercepted, it will
automatically expire, using the wording “即使请求被截获,也会自动失效” while leaving the
surrounding guidance unchanged.

In `@packages/orchestration/src/queue.ts`:
- Line 76: Update packages/orchestration/src/queue.ts lines 76-76 to create
QUEUE_TRIGGER_FIRE with policy: "exclusive" and apply the same
converge-by-recreate handling used by QUEUE_NOTIFICATION_DELIVER. At lines
116-125, retain singletonKey: deliveryId and revise the comment to attribute
deduplication to the exclusive queue policy rather than the key alone.

---

Outside diff comments:
In `@packages/orchestration/src/schedule-fire.ts`:
- Around line 135-161: Separate submitTask from the taskSchedules bookkeeping
update in fireSchedule: only submission errors should invoke fail() and produce
a failed outcome. After a successful submission, update lastFiredAt, lastRunId,
and related fields in a distinct error-handling path that logs bookkeeping
failures without reclassifying or retrying the already-submitted task; preserve
the submitted result.

---

Duplicate comments:
In `@apps/worker/src/index.ts`:
- Around line 283-291: Update both the schedule-fire consumer at
apps/worker/src/index.ts lines 273-281 and the trigger delivery consumer at
apps/worker/src/index.ts lines 283-291 to include the outcome’s error or reason
alongside outcome.kind in their structured logger calls. Preserve the existing
outcome handling and use the appropriate detail for failed, disabled, and
skipped outcomes.

In `@docs/design/04-execution-runtime.md`:
- Line 147: Update the schedule authority paragraph to reference the implemented
checkWorkAuthority helper instead of assertProjectRole, or describe the
fire-time membership validation without naming a helper. Keep the existing
explanation of owner-based authorization and attribution unchanged.

In `@packages/orchestration/src/schedule-fire.ts`:
- Around line 162-174: Update the catch handling in fireSchedule so only
AppError instances are converted to the recoverable fail(message) outcome;
rethrow plain Error and other unexpected thrown values so pg-boss can retry
infrastructure failures from submitTask or queue-name derivation.
- Around line 56-79: Update the disable function so marking the schedule
disabled and completing its audit, notification, and queue unregistration are
resilient to failures after the database state change. Ensure those follow-up
actions are retried or otherwise completed independently of the early
!schedule?.enabled path, while preserving the disabled outcome and reason.

---

Nitpick comments:
In `@apps/api/src/routes/trigger-inbound.ts`:
- Around line 87-96: Update payload normalization in the raw JSON parsing block
to distinguish plain object payloads from arrays before assigning to the
Record<string, unknown> payload. Remove the `as never` cast, preserve scalar
wrapping via `{ value: parsed }`, and explicitly normalize array values into an
object-compatible representation.

In `@apps/api/src/test/triggers.integration.test.ts`:
- Around line 103-107: Add an explicit expect(taskTypeId).toBeTruthy() assertion
in the beforeAll setup immediately after locating the "weekly-report" task type,
before later tests use taskTypeId, so missing seed data fails during setup
rather than through unrelated validation errors.
- Around line 251-255: Update the “caps the payload size” test to import and use
TRIGGER_PAYLOAD_MAX_BYTES, constructing a payload larger than that configured
limit instead of relying on the hardcoded 70_000 value while preserving the
expected 413 response.

In `@apps/web/src/pages/SettingsPage.tsx`:
- Around line 1077-1080: Update the trigger deliveries useQuery in SettingsPage
to poll for refreshed results by adding a 10-second refetch interval, matching
NotificationsSection’s existing delivery polling behavior while preserving the
current query key and query function.
- Line 1236: Type the default scopes and toggleScope state updates against the
API_KEY_SCOPES allow-list by using the ApiKeyScope type instead of bare string
identifiers. Update toggleScope to accept ApiKeyScope so renamed or removed
scope values are caught by the compiler while preserving the existing defaults.

In `@apps/web/src/pages/SubmitTaskPage.tsx`:
- Around line 277-282: Update the signing secret Input identified by id
"trigger-secret" in SubmitTaskPage to use password masking and disable browser
autocomplete, matching the notification secret field’s behavior in SettingsPage
while preserving its existing value and change handling.

In `@packages/core/src/schemas.ts`:
- Around line 358-369: Extend triggerUpdateSchema with an optional secret field
using the same validation and length bounds as triggerCreateSchema, then update
the PATCH handler to detect a provided secret, re-encrypt it, and persist the
resulting secretRef while preserving the existing secret when omitted.

In `@packages/db/src/schema/triggers.ts`:
- Line 72: Update the disabledReason column in the triggerDeliveries schema to
use the shared TriggerDisabledReason vocabulary from packages/core, matching the
enum-style typing used by status. First verify that packages/db may depend on
`@agrippa/core`; if that dependency is disallowed, leave disabledReason untyped
rather than introducing an invalid import.

In `@packages/orchestration/src/authority.ts`:
- Line 15: Update WorkAuthorityFailure in authority.ts to derive its union from
the shared TRIGGER_DISABLED_REASONS definition in `@agrippa/core`, or centralize
both declarations on one exported shared list. Ensure checkWorkAuthority and
trigger_endpoints.disabled_reason use the same source of truth so any future
reason additions are compiler-checked.

In `@packages/orchestration/src/trigger-fire.ts`:
- Around line 77-99: Update the denied branch in the trigger-fire flow to wrap
the triggerEndpoints update, markAttempt, and auditAs calls in a single
db.transaction, preserving their current operations and data. Keep
notifyProjectEvent outside and after the transaction commits, then return the
existing disabled result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a27615ad-09ca-4286-8ab6-43502212a672

📥 Commits

Reviewing files that changed from the base of the PR and between bd84369 and 7cc5695.

📒 Files selected for processing (40)
  • CHANGELOG.md
  • apps/api/src/app.ts
  • apps/api/src/lib/task-params.ts
  • apps/api/src/routes/schedules.ts
  • apps/api/src/routes/trigger-inbound.ts
  • apps/api/src/routes/triggers.ts
  • apps/api/src/test/helpers.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/web/src/lib/types.ts
  • apps/web/src/pages/SettingsPage.tsx
  • apps/web/src/pages/SubmitTaskPage.tsx
  • apps/worker/src/deps/notify.ts
  • apps/worker/src/index.ts
  • docs/design/04-execution-runtime.md
  • docs/design/05-api-and-auth.md
  • docs/manual/en/04-administration.md
  • docs/manual/zh-CN/04-administration.md
  • packages/core/src/index.ts
  • packages/core/src/notifications.ts
  • packages/core/src/queue.ts
  • packages/core/src/schemas.ts
  • packages/core/src/triggers.ts
  • packages/db/drizzle/0025_trigger_endpoints.sql
  • packages/db/drizzle/meta/0025_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/index.ts
  • packages/db/src/schema/triggers.ts
  • packages/i18n/locales/en/catalog.json
  • packages/i18n/locales/en/errors.json
  • packages/i18n/locales/en/notifications.json
  • packages/i18n/locales/en/settings.json
  • packages/i18n/locales/zh-CN/catalog.json
  • packages/i18n/locales/zh-CN/errors.json
  • packages/i18n/locales/zh-CN/notifications.json
  • packages/i18n/locales/zh-CN/settings.json
  • packages/orchestration/src/authority.ts
  • packages/orchestration/src/index.ts
  • packages/orchestration/src/queue.ts
  • packages/orchestration/src/schedule-fire.ts
  • packages/orchestration/src/trigger-fire.ts
🚧 Files skipped from review as they are similar to previous changes (8)
  • apps/worker/src/deps/notify.ts
  • packages/orchestration/src/index.ts
  • packages/i18n/locales/zh-CN/errors.json
  • apps/api/src/app.ts
  • apps/api/src/routes/schedules.ts
  • apps/api/src/test/helpers.ts
  • packages/core/src/index.ts
  • packages/core/src/notifications.ts

Comment thread apps/api/src/routes/trigger-inbound.ts Outdated
Comment thread apps/api/src/routes/triggers.ts
Comment thread apps/api/src/routes/triggers.ts
Comment on lines +132 to +140
it("is project-admin gated and rejects params that could never run", async () => {
expect(
(
await member.request(`/api/v1/projects/${projectId}/triggers`, {
method: "POST",
json: { name: "nope", taskTypeId, params, secret: SECRET },
})
).status,
).toBe(403);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add member to the project so the admin gate is actually tested.

member never joins the project, so line 140 only proves that a non-member is rejected. requireProjectRole("member") would reject that user too. If the route is ever weakened from admin to member, this test still passes. Add the user as a project member with role member first.

💚 Proposed strengthening
   it("is project-admin gated and rejects params that could never run", async () => {
+    await admin.request(`/api/v1/projects/${projectId}/members`, {
+      method: "POST",
+      json: { email: member.email, role: "member" },
+    });
     expect(
📝 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
it("is project-admin gated and rejects params that could never run", async () => {
expect(
(
await member.request(`/api/v1/projects/${projectId}/triggers`, {
method: "POST",
json: { name: "nope", taskTypeId, params, secret: SECRET },
})
).status,
).toBe(403);
it("is project-admin gated and rejects params that could never run", async () => {
await admin.request(`/api/v1/projects/${projectId}/members`, {
method: "POST",
json: { email: member.email, role: "member" },
});
expect(
(
await member.request(`/api/v1/projects/${projectId}/triggers`, {
method: "POST",
json: { name: "nope", taskTypeId, params, secret: SECRET },
})
).status,
).toBe(403);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/triggers.integration.test.ts` around lines 132 - 140, Add
member to the project with the member role before the request in the “is
project-admin gated and rejects params that could never run” test. Keep the
existing request and 403 assertion so the test verifies an authenticated project
member is denied by the admin gate, not merely rejected for lacking membership.

Comment on lines +251 to +254
void navigator.clipboard
.writeText(url)
.then(() => toast.success(t("catalog:trigger.copied")));
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle clipboard failures for one-time secrets. Both copy handlers call navigator.clipboard.writeText with no failure path. navigator.clipboard is undefined outside a secure context, so the property access throws inside the click handler. If the browser denies permission, the promise rejects unhandled. Each copied value appears exactly once, so a silent failure loses it.

  • apps/web/src/pages/SubmitTaskPage.tsx#L251-L254: wrap the writeText call so the property access cannot throw, then add a catch that shows a localized failure toast for the trigger URL.
  • apps/web/src/pages/SettingsPage.tsx#L1335-L1339: apply the same guard and failure toast for the plaintext API key.

Add the new failure message to packages/i18n/locales/en/*.json and packages/i18n/locales/zh-CN/*.json.

As per coding guidelines: "Every user-facing string must have both en and zh-CN translations; locale resources must maintain key parity."

🛡️ Proposed fix for the trigger URL site
                   onClick={() => {
-                    void navigator.clipboard
-                      .writeText(url)
-                      .then(() => toast.success(t("catalog:trigger.copied")));
+                    void Promise.resolve()
+                      .then(() => navigator.clipboard.writeText(url))
+                      .then(() => toast.success(t("catalog:trigger.copied")))
+                      .catch(() => toast.error(t("catalog:trigger.copyFailed")));
                   }}
📍 Affects 2 files
  • apps/web/src/pages/SubmitTaskPage.tsx#L251-L254 (this comment)
  • apps/web/src/pages/SettingsPage.tsx#L1335-L1339
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/SubmitTaskPage.tsx` around lines 251 - 254, Update the
clipboard handlers in apps/web/src/pages/SubmitTaskPage.tsx lines 251-254 and
apps/web/src/pages/SettingsPage.tsx lines 1335-1339 to guard navigator.clipboard
before calling writeText, handle synchronous access failures, and catch rejected
promises with localized failure toasts for the trigger URL and plaintext API key
respectively. Add matching failure-message translation keys to the relevant en
and zh-CN JSON locale files while preserving locale key parity.

Source: Coding guidelines

计划**以创建者的身份执行**,且该权限在每次触发时重新校验,而非在创建时固化:它能做的事与创建者当下能做的完全一致,绝不更多。当某些条件永久性地不再成立时——创建者失去本项目权限、项目被归档、任务类型被下线——计划会被**停用并主动通知**,同时写入通知端点与审计日志,而不是悄悄跳过。这是有意为之:静默停止的计划与从未创建过的计划无法区分,等你发现时可能已过去数周。可修复的问题(配额耗尽、资源未授权、没有具备所需执行器的工作节点)则不同——计划保持启用,错误显示在其条目上并以 `schedule.failed` 发出,下次触发会再次尝试。重新启用已停用的计划会清除其停用原因;底层问题是否真的修好,留待下次触发时重新校验。
- **Webhook 触发器** —— 一个供外部系统 POST 的 URL,用于启动任务:CI 完成、部署上线、表单提交等。与定时计划一样从任务创建(任务目录 → 填写表单 → **Webhook 触发**),在此管理。

接入前有两点需要了解。该 URL 是一种**能力凭证**——任何持有它的人都能触达你的触发器——因此**必须设置签名密钥**,每个请求都要带上用它计算的 `x-agrippa-timestamp` 与 `x-agrippa-signature: v1=<对 "时间戳.正文" 的 HMAC-SHA256>`。超过五分钟的请求会被拒绝,因此被截获的请求会自动失效。创建时对话框会给出可直接粘贴的 `curl` 示例;URL 只显示这一次。另外,平台在**记录请求的那一刻就返回 202**,此时执行尚未产生——发送方只会知道请求被接受,永远不会知道执行是否成功。这正是触发器失效时会主动通知你(`trigger.disabled`、`trigger.failed`)而不是等发送方反馈的原因:发送方早已被告知一切正常。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the intercepted-request sentence.

被截获的请求会自动失效 is awkward in this sentence. Use 即使请求被截获,也会自动失效 so the condition is explicit.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~55-~55: 能愿动词不能成为‘把’字句、‘被’字句的谓语动词。应该是:"会被……截获"。
Context: ..."时间戳.正文" 的 HMAC-SHA256>。超过五分钟的请求会被拒绝,因此被截获的请求会自动失效。创建时对话框会给出可直接粘贴的 curl` 示例;URL 只显示这一...

(wa3)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/manual/zh-CN/04-administration.md` at line 55, Update the
intercepted-request sentence in the administration documentation to explicitly
state that even if a request is intercepted, it will automatically expire, using
the wording “即使请求被截获,也会自动失效” while leaving the surrounding guidance unchanged.

Source: Linters/SAST tools

Comment thread packages/orchestration/src/queue.ts Outdated
hutusi added 2 commits August 3, 2026 19:44
…e key binding lives

Two things found auditing my own branch before review rather than after.

The inbound trigger checked payload size *after* `c.req.text()`, so an
oversized send was already resident in memory by the time we objected to it.
The daemon event batch does this correctly — bounding by Content-Length before
parsing, with a comment saying why — and design/05 records that as the
standard, so this was a gap against a rule the codebase already had. Both
checks now run: the header check keeps the common case from being paid for at
all, and the post-read check stays as the backstop, because Content-Length is
absent under chunked encoding and only the second one cannot be evaded.

Separately, `assertProjectRole` is where an API key's project binding is
enforced, and every id-based route in execution.ts does reach project data
through it — I checked all twelve. But `GET /checkpoints/pending` scopes by
joining `project_members` directly, so a bound key would see rows from every
project its owner belongs to. That route is unreachable by keys today, which
means the endpoint's safety is a property of the allow-list rather than of the
handler. Since the mistake would be made by adding a line to the allow-list,
the warning belongs there, which is where it now is.
…e, disable atomicity

All seven findings verified against source before changing anything; none was
refuted, and four turned out worse than reported. Six of the seven are places
where the code contradicted a rule this repo had already written down.

**Post-commit enqueue is best-effort (P1).** submitTask commits the task and
run, then enqueues — and the comment two lines above that send says the
worker's straggler sweep makes it loss-proof. But fireSchedule and fireTrigger
treated any throw as a submission failure, so a transient queue error produced
a run the sweeper executed AND a second one on retry, while the delivery row
said failed with no run id and a schedule's lastRunId never advanced, blinding
its own skip/replace policy. The duplicate needed no operator: the catch path
itself enqueues a notification, fails identically, escapes the handler, and
pg-boss retries the whole firing. Fixed at all five post-commit sites, each of
which already had a sweeper behind it.

Found while verifying, not in the report: the 60s sweep was one try block, so a
single straggler with an underivable executor set aborted lease expiry,
offline-runtime detection, checkpoint recovery and both delivery sweeps — every
tick, forever, behind one log line. A recovery mechanism one bad row can
disable is not a recovery mechanism. Each stage is now isolated.

**trigger.fire dedupe never worked (P1).** It was created with pg-boss's
default `standard` policy while its sends passed singletonKey. The comment ten
lines above says "'standard' has no singleton index at all"; an existing test
asserts it; design/04 states it — and my own new sentence in the same file
claimed the opposite. Now `exclusive`, with fireTrigger claiming a delivery by
CAS on its attempts counter (the pattern deliverNotification already used)
rather than reading its status and hoping.

**Accepted deliveries could strand forever (P1).** No sweeper touched
trigger_deliveries — the partial index one would need was already there,
unused. Worse, a stranded pending row was unreachable through the product:
Retry only moves failed → pending, and the UI offers it only on failed. Added
sweepTriggerDeliveries; a duplicate delivery-id whose original never got a job
now re-enqueues instead of silently answering 200.

**Disable could stop silently (P2).** The enabled flag was flipped first and
the audit, notification and cron cleanup came after — and that flag is exactly
what makes the retry skip. A throw in between lost all three permanently, the
run-less notification being unreconstructible by any sweeper. So the feature
whose whole premise is "a schedule that stops always says so" could stop
without a word. Flag, audit row and delivery rows now commit together; boot
reconciliation also unregisters disabled schedules, which it never did.

**CRUD 500'd after committing (P2).** scheduleRoutes' comment claimed
registration was "best-effort at request time on purpose" while the code
awaited it unguarded — so a queue hiccup returned 500 for a schedule that
existed, was enabled, and would start firing at the next worker boot, with the
audit log recording success. The comment is now true.

**The body cap was not a cap (P2).** My own previous commit claimed to fix
this. It did not: Number("abc") is NaN and NaN > limit is false, so a junk
Content-Length walked straight past it, and chunked encoding omits the header
entirely — leaving the runtime's 128 MiB as the real ceiling, ~2048x the
documented 64 KiB, reachable with the URL token alone since the signature
necessarily comes after the read. The body is now bounded while being read, and
rejects rather than truncates: truncation would change the bytes the signature
covers and surface as a wrong-secret error.

**A deleted trigger kept its secret (P2).** No reverse cascade, and the row
that pointed at the ciphertext was the only thing that could find it — so every
deleted trigger left live key material the operator believed was destroyed, and
unreachable by key rotation. Now deleted in the same transaction, matching what
notification endpoints and provider credentials have always done.

Ten regression tests, one per finding that can carry one, including a
concurrent double-fire (the existing "never twice" test fired sequentially and
could not have caught the race) and a chunked body with no Content-Length
(fetch always sets one, so the old test only exercised the header path).

Docs close Track T's four gaps and the drift they inherited: 01-domain-model
gains the three tables and a schema-file list that was missing six of sixteen;
06-frontend gains the four Settings sections it lacked — including Track N's
notifications — the two Submit dialogs, and Phase B's Workers page;
ARCHITECTURE's orchestration codemap was eight modules stale and its apps/api
row still claimed submission logic that moved out of that package on this
branch, which would send a contributor to the wrong package; m2-plan ticks
Track T. design/04's self-contradiction about singleton keys is corrected, and
a new invariant records that unattended work names a human whose authority is
re-checked at the point of use.

Verified: check, build, templates, and 572 pass / 5 skip / 0 fail with
Postgres connections reclaimed first. Browser smoke re-run — trigger created,
signed POST accepted, worker produced a run with its date tokens resolved.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/orchestration/src/submit.ts (1)

70-70: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Serialize the archive check with task creation.

assertProjectAcceptsWork completes before the transaction starts at Line 107. If an admin archives the project after Line 70, this function can still commit a new task and run. Re-check and lock the project row inside the insert transaction, or use a conditional insert tied to active project status. Add an integration test that races archival with submission.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/submit.ts` at line 70, Move the project acceptance
validation into the task-creation transaction in the submit flow, using a row
lock or conditional insert that prevents creating a task once the project is
archived; do not rely on the earlier assertProjectAcceptsWork call alone. Add an
integration test that races project archival against submission and verifies no
task is committed after archival.
apps/api/src/routes/trigger-inbound.ts (1)

143-143: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

An empty delivery-ID header deduplicates every later event.

c.req.header() returns "" when the header is present but empty. ?? does not replace "", so externalId becomes "". "" is not NULL, so the partial index trigger_deliveries_dedupe_uq applies to it. The first such delivery inserts, and every later delivery from the same endpoint with an empty header conflicts, returns 200 deduplicated, and never runs. Treat blank as absent.

🐛 Proposed fix
-  const externalId = c.req.header(TRIGGER_DELIVERY_ID_HEADER)?.slice(0, 200) ?? null;
+  const externalId = c.req.header(TRIGGER_DELIVERY_ID_HEADER)?.trim().slice(0, 200) || null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/trigger-inbound.ts` at line 143, Update the externalId
extraction in the inbound trigger route to treat an empty or whitespace-only
TRIGGER_DELIVERY_ID_HEADER value as absent, producing null before the value is
persisted or used for deduplication. Preserve the existing 200-character limit
for non-blank header values.
🧹 Nitpick comments (2)
apps/api/src/test/schedules.integration.test.ts (1)

425-440: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore the shared fixtures that this test mutates.

The test deletes the owner's projectMembers row and inserts a notificationEndpoints row named blocker. Neither is restored. The endpoint uses events: [], which the schema treats as every notifiable event, so it receives deliveries for every later test in this process. Any test added after this one that counts notificationDeliveries rows gets extra rows from blocker. Delete the endpoint in a finally block or an afterEach hook.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 425 - 440, The
test setup mutates shared fixtures without restoring them: update the cleanup
around the test’s projectMembers deletion and blocker notificationEndpoints
insertion to run in a finally block or afterEach hook, deleting the inserted
blocker endpoint and restoring the owner membership so later tests see the
original state.
apps/api/src/routes/trigger-inbound.ts (1)

144-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Write an audit row for accepted inbound trigger deliveries.

This route inserts triggerDeliveries and calls queue enqueue, but never writes the required API audit entry. Record the accepted delivery with auditAs, using a public/endpoint actor; if the public delivery row is intended as the audit trail for this unauthenticated ingress surface, document that explicit exception at this route.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/trigger-inbound.ts` around lines 144 - 155, After the
successful triggerDeliveries insert in the inbound delivery route, record the
accepted delivery through auditAs using a public/endpoint actor, preserving the
existing duplicate-conflict behavior and queue flow. If the public delivery
record is intentionally serving as the audit trail for this unauthenticated
ingress path, document that exception directly in the route.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/test/schedules.integration.test.ts`:
- Around line 465-469: Update the deliveries query in the schedule-disabled
retry test to filter by both eventType and the payload condition for this
schedule’s scheduleId, reusing the existing scheduleId payload expression. Keep
the assertion unchanged so it only passes when this retry’s notification
delivery was committed.

In `@apps/api/src/test/triggers.integration.test.ts`:
- Around line 519-529: Update the junk Content-Length case in the trigger
integration test so it reliably reaches the handler with a non-numeric declared
length, avoiding the forbidden-header stripping behavior. Ensure the test
directly exercises and asserts rejection of the bogus value through the
handler’s Number.isFinite(declared) guard rather than relying on the body-stream
limit response.

In `@apps/worker/src/index.ts`:
- Around line 309-317: Update the schedule reconciliation loop in the startup
handler around registerSchedule and unregisterSchedule so each row is processed
inside its own try/catch, logging the row-specific failure and continuing with
subsequent rows. Track successfully reconciled rows separately and use that
count in the final deps.logger.info message instead of rows.length.

In `@packages/orchestration/src/trigger-fire.ts`:
- Around line 78-90: Update the trigger delivery retry route to set
lastAttemptAt to null alongside status: "pending" and clearing lastError,
matching the existing notification retry path. Ensure fireTrigger can
immediately claim the retried delivery without being blocked by the 20-second
timestamp guard.
- Around line 197-223: Update both the exhaustion update and stale-delivery
selection in the trigger sweep to require an attempt-staleness condition based
on lastAttemptAt, rather than relying only on pending status and, for the batch
query, createdAt. Ensure in-flight attempts are excluded from failure marking
and re-enqueueing while preserving the existing retry limits and stale batch
ordering.

---

Outside diff comments:
In `@apps/api/src/routes/trigger-inbound.ts`:
- Line 143: Update the externalId extraction in the inbound trigger route to
treat an empty or whitespace-only TRIGGER_DELIVERY_ID_HEADER value as absent,
producing null before the value is persisted or used for deduplication. Preserve
the existing 200-character limit for non-blank header values.

In `@packages/orchestration/src/submit.ts`:
- Line 70: Move the project acceptance validation into the task-creation
transaction in the submit flow, using a row lock or conditional insert that
prevents creating a task once the project is archived; do not rely on the
earlier assertProjectAcceptsWork call alone. Add an integration test that races
project archival against submission and verifies no task is committed after
archival.

---

Nitpick comments:
In `@apps/api/src/routes/trigger-inbound.ts`:
- Around line 144-155: After the successful triggerDeliveries insert in the
inbound delivery route, record the accepted delivery through auditAs using a
public/endpoint actor, preserving the existing duplicate-conflict behavior and
queue flow. If the public delivery record is intentionally serving as the audit
trail for this unauthenticated ingress path, document that exception directly in
the route.

In `@apps/api/src/test/schedules.integration.test.ts`:
- Around line 425-440: The test setup mutates shared fixtures without restoring
them: update the cleanup around the test’s projectMembers deletion and blocker
notificationEndpoints insertion to run in a finally block or afterEach hook,
deleting the inserted blocker endpoint and restoring the owner membership so
later tests see the original state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0176687a-54db-4c3a-a85b-929e519411d6

📥 Commits

Reviewing files that changed from the base of the PR and between 7cc5695 and 4ba495b.

📒 Files selected for processing (20)
  • ARCHITECTURE.md
  • CHANGELOG.md
  • apps/api/src/lib/api-key-routes.ts
  • apps/api/src/routes/execution.ts
  • apps/api/src/routes/schedules.ts
  • apps/api/src/routes/trigger-inbound.ts
  • apps/api/src/routes/triggers.ts
  • apps/api/src/test/schedules.integration.test.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/worker/src/index.ts
  • docs/design/01-domain-model.md
  • docs/design/04-execution-runtime.md
  • docs/design/06-frontend.md
  • docs/plan/m2-plan.md
  • packages/orchestration/src/notifications.ts
  • packages/orchestration/src/queue.integration.test.ts
  • packages/orchestration/src/queue.ts
  • packages/orchestration/src/schedule-fire.ts
  • packages/orchestration/src/submit.ts
  • packages/orchestration/src/trigger-fire.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/orchestration/src/schedule-fire.ts
  • docs/design/04-execution-runtime.md
  • apps/api/src/routes/schedules.ts
  • apps/api/src/routes/execution.ts
  • apps/api/src/routes/triggers.ts
  • apps/api/src/lib/api-key-routes.ts

Comment on lines +465 to +469
const deliveries = await db
.select()
.from(notificationDeliveries)
.where(eq(notificationDeliveries.eventType, "schedule.disabled"));
expect(deliveries.length).toBeGreaterThan(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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "=== imports in the test file ==="
fd -t f 'schedules.integration.test.ts' apps --exec sed -n '1,40p'
echo "=== insertProjectEventDeliveries definition ==="
ast-grep run --pattern 'export async function insertProjectEventDeliveries($$$) { $$$ }' --lang typescript packages/orchestration/src

Repository: ainaive/agrippa

Length of output: 1097


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== locate schedules integration file and insertProjectEventDeliveries ==="
fd -t f 'schedules.integration.test.ts|.*schedule.*|deliveri' . | sed -n '1,120p'
echo
echo "=== file path ==="
fd -t f 'schedules.integration.test.ts' . -x echo "{}"
echo
echo "=== search insertProjectEventDeliveries ==="
rg -n "insertProjectEventDeliveries|scheduleId|notifications?Deliveries|eventType|schedule.disabled" . -g '*.ts' | sed -n '1,220p'

Repository: ainaive/agrippa

Length of output: 20796


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== test file imports and focused tests ==="
cat -n apps/api/src/test/schedules.integration.test.ts | sed -n '1,525p'

echo
echo "=== notification delivery schema ==="
cat -n packages/db/src/schema/notifications.ts | sed -n '50,85p'

echo
echo "=== schedule disabled code path ==="
cat -n packages/orchestration/src/schedule-fire.ts | sed -n '70,92p'

Repository: ainaive/agrippa

Length of output: 24625


Scope the delivery assertion to this schedule.

The suite creates a schedule.disabled delivery earlier, so the current eventType filter can pass even if the retry does not commit this retry’s notification. Add the payload condition used for scheduleId.

💚 Proposed fix
     const deliveries = await db
       .select()
       .from(notificationDeliveries)
-      .where(eq(notificationDeliveries.eventType, "schedule.disabled"));
+      .where(
+        and(
+          eq(notificationDeliveries.eventType, "schedule.disabled"),
+          sql`${notificationDeliveries.payload} ->> 'scheduleId' = ${row.id}`,
+        ),
+      );
     expect(deliveries.length).toBeGreaterThan(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
const deliveries = await db
.select()
.from(notificationDeliveries)
.where(eq(notificationDeliveries.eventType, "schedule.disabled"));
expect(deliveries.length).toBeGreaterThan(0);
const deliveries = await db
.select()
.from(notificationDeliveries)
.where(
and(
eq(notificationDeliveries.eventType, "schedule.disabled"),
sql`${notificationDeliveries.payload} ->> 'scheduleId' = ${row.id}`,
),
);
expect(deliveries.length).toBeGreaterThan(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/schedules.integration.test.ts` around lines 465 - 469,
Update the deliveries query in the schedule-disabled retry test to filter by
both eventType and the payload condition for this schedule’s scheduleId, reusing
the existing scheduleId payload expression. Keep the assertion unchanged so it
only passes when this retry’s notification delivery was committed.

Comment on lines +519 to +529
const junk = await app.request(`/api/triggers/${t.token}`, {
method: "POST",
headers: {
"content-type": "application/json",
"content-length": "not-a-number",
[TRIGGER_TIMESTAMP_HEADER]: ts,
[TRIGGER_SIGNATURE_HEADER]: sig,
},
body,
});
expect(junk.status).toBe(413);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Is Content-Length a forbidden request header name in the Fetch standard, and does the Request constructor drop it?

💡 Result:

Yes, Content-Length is classified as a forbidden request header name in the Fetch standard [1][2]. Headers are marked as forbidden to ensure that the user agent (e.g., the browser) maintains full control over them, preventing developers from manually overriding headers that are critical to the underlying HTTP protocol's operation [2][3]. Regarding the Request constructor, the Fetch specification generally does not require it to drop the Content-Length header simply because it is forbidden; rather, the restriction applies to the programmatic modification of these headers [3]. However, in practice, because Content-Length is a forbidden request header, you cannot set it via the Headers object when constructing a Request [3]. If you attempt to include it in a Headers object passed to the Request constructor, it will be ignored or rejected depending on the implementation, as the user agent is responsible for calculating and setting the correct Content-Length based on the request body [4]. Consequently, while the header is forbidden to be manually set by developers, the user agent will automatically calculate and provide the appropriate Content-Length header for the request [4]. If an implementation drops it or sets it to zero unexpectedly, it is often due to the specific runtime environment's handling of the request body stream or its internal limitations rather than a direct mandate to drop the header during construction [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate files:"
fd -a 'triggers.integration.test.ts|audit.ts' . | sed 's#^\./##'

echo
echo "Candidate test snippet:"
if [ -f apps/api/src/test/triggers.integration.test.ts ]; then
  sed -n '450,550p' apps/api/src/test/triggers.integration.test.ts | cat -n
fi

echo
echo "Search triggers route/header handlers:"
rg -n "TRIGGER_TIMESTAMP_HEADER|TRIGGER_SIGNATURE_HEADER|content-length|Content-Length|number of bytes|body size|Request|app.request|handler" apps/api/src -g '*.ts' | head -200

Repository: ainaive/agrippa

Length of output: 13954


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "app.request helpers and type definitions:"
sed -n '1,150p' apps/api/src/test/helpers.ts | cat -n
rg -n "function request|const request|request:|app\.request|RequestInit|WorkerRuntime|Hono|app\.handle|c\.req\.header|header\\(" apps/api/src -g '*.ts' | head -200

echo
echo "trigger-inbound route:"
sed -n '80,145p' apps/api/src/routes/trigger-inbound.ts | cat -n

echo
echo "package/runtime hints:"
for f in package.json apps/api/package.json bunfig.toml wrangler.toml; do
  [ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done

Repository: ainaive/agrippa

Length of output: 19991


🌐 Web query:

Bun app.request Request headers forbidden headers Content-Length Request constructor

💡 Result:

In Bun, request headers are governed by standard Fetch API behaviors, with specific implementation nuances in the Bun runtime [1][2]. Regarding the Content-Length header and forbidden headers: 1. Forbidden Headers: Consistent with the Fetch API specification, certain headers are considered "forbidden" or guarded to prevent them from being tampered with by client-side code [3]. These headers are automatically managed by the user agent (Bun) and cannot be modified or removed by manual assignment in a Request object or fetch call [3]. This ensures the integrity of the HTTP protocol [3]. 2. Content-Length Behavior: - In standard fetch, the Content-Length header is typically managed automatically by the runtime [1]. If you provide a stream as a request body, Bun will often send the request with Transfer-Encoding: chunked because the total length may not be known upfront [4][5]. - There have been reported issues where using the node:http compatibility layer specifically ignores explicitly set Content-Length headers in favor of chunked encoding [4][5]. While fixes have been tracked in the Bun repository (e.g., issues #26976 and #24612), behavior in the node:http module may vary from standard fetch [4][5]. 3. Other Default Headers: Bun automatically adds certain headers (like User-Agent, Accept, and Accept-Encoding) to requests [6]. Users have reported that these cannot be fully removed even when explicitly cleared in the headers configuration, as the runtime re-applies them [6]. 4. Using app.request: When using app.request (often used in frameworks like Hono for testing), you are essentially invoking the application's internal request handling [7][8]. If you encounter issues (e.g., middleware failing), it is often because the headers object provided in the app.request options is being misinterpreted or colliding with middleware expectations, rather than a "forbidden header" restriction in the runtime itself [7][8]. Always ensure that the headers object provided to app.request is properly structured, preferably using a Headers object instance if standard object literals behave unexpectedly [8]. For the most reliable control over headers, use the standard Request and Headers APIs [2][3], and be aware that the runtime maintains ultimate control over mandatory protocol headers [3][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Hono test mode and app.request implementation:"
sed -n '45,75p' apps/api/src/app.ts | cat -n

echo
echo "Inspect `@hono/bun` and hono runtime/test helper files in lockfile/node_modules/cache if present:"
fd -a 'test-request|adapter|request|index.ts|hono' apps/api/node_modules 2>/dev/null | head -20 || true
rg -n "app\.request|new Request|headers:|content-length|contentLength|contentLength" apps/api/node_modules/@hono apps/api/node_modules/hono 2>/dev/null | head -200 || true

echo
echo "Read package-lock entries for hono:"
python3 - <<'PY'
import json, pathlib
for p in [pathlib.Path("bun.lock"), pathlib.Path("bun.lockb")]
PY

Repository: ainaive/agrippa

Length of output: 2166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Exact trigger-inbound size guard:"
nl -ba apps/api/src/routes/trigger-inbound.ts | sed -n '15,35p'

echo
echo "Hono app request construction in installed runtime:"
python3 - <<'PY'
import json, pathlib
for name in ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml"]:
    p = pathlib.Path(name)
    if p.exists():
        print(f"lockfile: {name}")
PY
if [ -d apps/api/node_modules/hono ]; then
  rg -n "app\.request|new Request|setHeaders|content-length|contentLength|forbidden|forb" apps/api/node_modules/hono apps/api/node_modules/@hono 2>/dev/null | head -200
else
  echo "no apps/api/node_modules/hono"
fi

echo
echo "Relevant installed Hono files:"
git ls-files 'apps/api/node_modules/hono/**/*.ts' 'apps/api/node_modules/hono/**/*.js' | rg 'test|request|adapter' | head -100 || true

echo
echo "If bun is available, behavioral probe for Headers/Request Content-Length header:"
tmp="$(mktemp -d)"
cat > "$tmp/probe.js" <<'JS'
const h1 = new Headers({ "content-length": "not-a-number"]);
try {
  const req = new Request("http://x/", { method: "POST", body: "x" });
  console.log("Request empty headers contain content-length:", req.headers.get("content-length") !== null);
  console.log("Request empty headers content-length:", req.headers.get("content-length"));
} catch (e) {
  console.log("Request empty error:", e && e.message);
}
try {
  const req2 = new Request("http://x/", { method: "POST", body: "x", headers: h1 });
  console.log("Manual Content-Length header value:", req2.headers.get("content-length"));
} catch (e) {
  console.log("Manual Content-Length error:", e && e.message);
}
JS
if command -v bun >/dev/null 2>&1; then
  bun "$tmp/probe.js" || true
else
  echo "bun not available"
fi
node "$tmp/probe.js" || true

Repository: ainaive/agrippa

Length of output: 227


Use a bypass-proof way to send junk Content-Length.

Content-Length is a forbidden request header, so this request may strip "not-a-number" before the route sees it. In that case, the body-stream counter returns 413, and the test does not catch a regression in Number.isFinite(declared). Make this case carry a junk value the handler can read, or assert that the guard rejects bogus declared lengths directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/triggers.integration.test.ts` around lines 519 - 529,
Update the junk Content-Length case in the trigger integration test so it
reliably reaches the handler with a non-numeric declared length, avoiding the
forbidden-header stripping behavior. Ensure the test directly exercises and
asserts rejection of the bogus value through the handler’s
Number.isFinite(declared) guard rather than relying on the body-stream limit
response.

Comment thread apps/worker/src/index.ts Outdated
Comment thread packages/orchestration/src/trigger-fire.ts
Comment thread packages/orchestration/src/trigger-fire.ts
hutusi added 2 commits August 3, 2026 21:00
… sweeping

Two Major findings against the round-1 commit itself, both verified.

Boot reconciliation awaited register/unregister with no per-row guard, inside
one try. Reconciliation runs only at startup, so one failing schedule left an
arbitrary remainder unreconciled for the life of the process with nothing to
retry it — and row order is unspecified, so which ones was a lottery. This is
precisely the failure mode fixed for the sweeper two functions away in the same
commit, missed in the adjacent loop. Now per row, and the count logs what
actually converged rather than what was considered.

`sweepTriggerDeliveries` measured row age instead of attempt age. A delivery
created long ago and re-attempted a moment ago read as stale, so it was
re-enqueued every tick, and — the real defect — the exhaustion update could
stamp `failed` on a row whose submit was still running, which the success path
then overwrote. Both statements now key off
`coalesce(last_attempt_at, created_at)`, the coalesce covering a never-claimed
row whose creation genuinely is its clock.

One correction to the report, recorded because it changes the severity rather
than the fix: it argued this permits a second submission and a second charge
once a submit exceeds the 20s CAS window. It does not. `trigger.fire` became an
`exclusive` queue in the same commit, and that policy's unique index spans
created/retry/active — so while the original job is still active a sweeper
re-enqueue cannot mint a second job at all. What remained was the spurious
`failed` stamp and pointless re-enqueue work, which is what this fixes.

573 pass / 5 skip / 0 fail, with a test pinning the distinction: an old row
with a current attempt is neither re-enqueued nor failed.
…y, audited mutations

All seven verified against source; none refuted. Six fully valid, one (HMAC)
valid in mechanism but not a regression and not a forgery risk. Three were
consequences of round 1, each because the fix went to the site the review named
rather than to the class of sites sharing the defect.

**Submission is atomic with its bookkeeping (P1).** submitTask committed the
run, then the caller recorded that it had happened. A crash in that gap left
the delivery pending or lastRunId stale, and the retry — whose claim window is
deliberately SHORTER than the retry delay, so legitimate retries get through —
submitted a second charged run. The exclusive queue policy is no help: the
duplicate is the same job re-executing, not a second job. submitTaskIn now
takes the caller's transaction; the enqueue stays outside as best-effort. The
read chain widened to DbOrTx, which the codebase's other tx-aware helpers
already took. Schedules were worse: their queue retries immediately with no
claim at all, and the lost lastRunId is exactly what the skip/replace check
reads, so it stopped skipping and "replaced" a run that had already finished.

Two more the report did not name. The try spanned the bookkeeping as well as
the submission, so a failure after the run committed marked the delivery failed
and fired trigger.failed for a run that was alive and billing; the try now
covers only the submission. And Retry gated on status alone, so clicking it on
such a delivery minted yet another run — it now refuses any delivery with a
run_id.

**The rest.** Both inbound enqueues were still bare, so a queue outage 500'd an
accepted delivery and the sender's retry — lacking the *optional* delivery-id
header — inserted a second one. The trigger.failed path wrote its terminal
status separately from its announcement, the same shape fixed for the disable
path one branch above, and `failed` is what makes the retry's claim reject, so
the notification was lost for good. Retry left attempts and lastAttemptAt
untouched, so an exhausted delivery was re-failed the moment it aged and a
click within 20s silently no-opped — both hazards the notification retry's own
comment names. The HMAC hashed decoded text, so a body with invalid UTF-8 could
never verify and was reported as a wrong secret; it now hashes wire bytes and
decodes after.

**Schedule registration got the recovery half it was missing.** Round 1 made it
best-effort so a hiccup would stop 500-ing a schedule that existed — but
reconciliation ran only at boot, which traded a loud wrong answer for a silent
one: the schedule never fired until a restart, and an edited one kept its old
cron while the UI showed the new. A schedule-calendar sweeper stage now diffs
the rows against boss.getSchedules each tick, writing nothing when converged.
It earned its keep immediately: the boot pass removed an orphaned entry left by
a schedule deleted in an earlier smoke, which nothing could previously reap.
enqueueAfterCommit's docstring is corrected too — it asserted that every
post-commit send has a sweeper behind it, and this was the call site where that
was false while its warning told operators otherwise.

**Audits ride their mutation's transaction at the nine new sites** (ADR-0014
decision 3). Scoped deliberately: all 50 route call sites in the codebase are
non-transactional, so this is a long-standing pattern rather than a Track T
regression, and the other 41 are recorded as their own change rather than
enlarging a PR under review. The API-key case was worth not deferring — a
failed audit after the key row committed left a live credential whose only
plaintext died with the request and whose creation the audit log never knew.

581 pass / 5 skip / 0 fail (was 573), with eight regressions including a
rollback proof for the atomic submission, an invalid-UTF-8 signature, a
retried-then-swept delivery, and calendar drift repaired without a restart.
Browser smoke re-run: trigger created, signed POST accepted, worker produced
the run.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/orchestration/src/schedule-fire.ts (1)

193-205: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Rethrow errors that are not attributable submission failures.

The catch converts every thrown value into fail(message) and then returns normally. The comment on lines 194-197 claims that only fixable configuration or a transient shortage reaches this point. That does not hold for the whole call. submitTaskIn throws a plain Error for "task insert failed" and "run insert failed", and the surrounding db.transaction propagates driver faults such as a lost connection, a deadlock, or a serialization failure.

For those faults the schedule records a firing failure, announces it, and returns { kind: "failed" }. The worker consumer sees a normal return, so pg-boss does not retry the job. The firing is lost until the next cron occurrence. For a weekly schedule that is a week of silence, and the recorded error names an infrastructure fault as a configuration problem.

Classify the error: convert AppError and SubmitError through fail, and rethrow everything else so pg-boss retries the firing.

🛡️ Proposed fix
   } catch (err) {
-    // Everything reaching here is fixable configuration or a transient
-    // shortage — quota exhausted, a revoked grant, no live worker for the
-    // executor set. The schedule stays on so next week's firing can succeed;
-    // the error is recorded and announced so the fix happens before then.
-    const message =
-      err instanceof AppError
-        ? `${err.code}: ${err.message}`
-        : err instanceof Error
-          ? err.message
-          : String(err);
-    return fail(message);
+    // Only an attributable submission failure — fixable configuration or a
+    // transient shortage — is recorded and announced. The schedule stays on so
+    // the next firing can succeed. Anything else is an infrastructure fault:
+    // rethrow it so pg-boss retries this firing instead of skipping it.
+    if (err instanceof AppError) return fail(`${err.code}: ${err.message}`);
+    if (err instanceof SubmitError) return fail(`${err.code}: ${err.message}`);
+    throw err;
   }

Import SubmitError from the module that declares it, and confirm its shape carries code.

As per coding guidelines: "Preserve engine error semantics: RunFailure and BudgetExceededError finalize the run, while unexpected errors must rethrow so pg-boss retries and the engine resumes at step granularity".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/schedule-fire.ts` around lines 193 - 205, Update
the catch block in the schedule-fire flow to pass only AppError and SubmitError
instances to fail(message), preserving their code-aware message formatting and
importing SubmitError from its declaring module. Rethrow all other errors,
including task/run insert failures and database driver faults, so pg-boss retry
semantics remain intact.

Source: Coding guidelines

🧹 Nitpick comments (3)
packages/orchestration/src/schedule-fire.ts (1)

252-270: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Report the per-row repair failure.

The catch on lines 267-269 discards the error. reconcileScheduleCalendar returns only aggregate counts, so a schedule whose register call fails on every tick produces no signal. The worker then logs +0/-0, which reads as a converged calendar while that schedule never fires.

Accept an optional reporter and call it in the catch, or return the failed ids so the worker can log them.

♻️ Proposed change
 export async function reconcileScheduleCalendar(
   db: Db,
   calendar: {
     list(): Promise<Array<{ key: string; cron: string; timezone: string }>>;
     register(scheduleId: string, cron: string, timezone: string): Promise<void>;
     unregister(scheduleId: string): Promise<void>;
   },
-): Promise<{ registered: number; unregistered: number }> {
+  onError?: (scheduleId: string, err: unknown) => void,
+): Promise<{ registered: number; unregistered: number }> {
@@
-    } catch {
+    } catch (err) {
+      onError?.(row.id, err);
       live.delete(row.id); // not an orphan; it has a row, just an unhappy one
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/schedule-fire.ts` around lines 252 - 270, Update
reconcileScheduleCalendar’s per-row catch to report the failed schedule instead
of silently discarding the error: accept an optional reporter and invoke it with
the row identifier and caught error, or return failed IDs for the worker to log.
Preserve the existing per-row isolation and live.delete behavior while ensuring
repeated register/unregister failures produce an observable signal.
apps/api/src/test/triggers.integration.test.ts (1)

649-654: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the no-op task-type disable and fix the comment.

Line 650 disables the task type, and line 654 re-enables it. Only send runs between them. send records and enqueues the delivery; it does not submit. fireTrigger runs on line 665, after the task type is enabled again, so the disable never affects the outcome. The catch path is reached by the invalid params written on lines 661-664. The comment on line 649 states a mechanism the test does not use, so a later edit could remove the params mutation and still expect a failure.

♻️ Proposed change
     const t = await createTrigger({ params: { dateRange: "x", rawNotes: "y" } });
-    // make submission fail so we reach the catch path
-    await db.update(taskTypes).set({ enabled: false }).where(eq(taskTypes.id, taskTypeId));
     const { deliveryId } = await jsonOf<{ deliveryId: string }>(
       await send(t.token as string, { event: "x" }),
     );
-    await db.update(taskTypes).set({ enabled: true }).where(eq(taskTypes.id, taskTypeId));

The params rewrite on lines 661-664 already forces submission to fail, so keep that as the single mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/triggers.integration.test.ts` around lines 649 - 654,
Remove the taskTypes updates that disable and re-enable the task type around
send, and update the preceding comment to state that the invalid params mutation
is what forces submission to fail and reaches the catch path. Keep the existing
params rewrite as the sole failure mechanism.
packages/orchestration/src/submit.ts (1)

102-103: 🗄️ Data Integrity & Integration | 🔵 Trivial

Consider serializing the quota check against concurrent submissions.

assertQuotaHeadroom now runs inside the caller transaction, and the inserts follow it. Two concurrent submissions in separate transactions both read the pre-commit run count, so both can pass the check and both can commit. A project can exceed its hard-stop quota by the number of concurrent submissions. Scheduled and trigger firings make concurrent submission more likely than the browser path did.

This shape predates the refactor, so it is not a regression. If the quota is a hard stop, enforce it with a row lock on the project quota row inside the transaction, or with a database constraint that the insert violates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/submit.ts` around lines 102 - 103, Serialize the
quota validation in the submit transaction by locking the project’s quota row
before assertQuotaHeadroom evaluates usage, ensuring concurrent submissions for
the same project cannot pass against the same pre-commit count. Update the flow
around assertQuotaHeadroom in submit.ts while preserving the existing hard-stop
rejection and subsequent inserts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/design/05-api-and-auth.md`:
- Line 21: Qualify the idempotency statement in the API/auth design text:
clarify that duplicate prevention via the unique (endpoint, external_id) key
applies only when callers provide a stable x-agrippa-delivery-id. State that
retries without this optional header may create another delivery and run,
without changing the header’s optional status.

In `@packages/db/src/catalog.ts`:
- Line 3: Remove the unused Db symbol from the type import in catalog.ts,
leaving DbOrTx available for loadProviderCatalog().

---

Duplicate comments:
In `@packages/orchestration/src/schedule-fire.ts`:
- Around line 193-205: Update the catch block in the schedule-fire flow to pass
only AppError and SubmitError instances to fail(message), preserving their
code-aware message formatting and importing SubmitError from its declaring
module. Rethrow all other errors, including task/run insert failures and
database driver faults, so pg-boss retry semantics remain intact.

---

Nitpick comments:
In `@apps/api/src/test/triggers.integration.test.ts`:
- Around line 649-654: Remove the taskTypes updates that disable and re-enable
the task type around send, and update the preceding comment to state that the
invalid params mutation is what forces submission to fail and reaches the catch
path. Keep the existing params rewrite as the sole failure mechanism.

In `@packages/orchestration/src/schedule-fire.ts`:
- Around line 252-270: Update reconcileScheduleCalendar’s per-row catch to
report the failed schedule instead of silently discarding the error: accept an
optional reporter and invoke it with the row identifier and caught error, or
return failed IDs for the worker to log. Preserve the existing per-row isolation
and live.delete behavior while ensuring repeated register/unregister failures
produce an observable signal.

In `@packages/orchestration/src/submit.ts`:
- Around line 102-103: Serialize the quota validation in the submit transaction
by locking the project’s quota row before assertQuotaHeadroom evaluates usage,
ensuring concurrent submissions for the same project cannot pass against the
same pre-commit count. Update the flow around assertQuotaHeadroom in submit.ts
while preserving the existing hard-stop rejection and subsequent inserts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b84bc123-0852-45c1-bf2f-53dc6fbd5831

📥 Commits

Reviewing files that changed from the base of the PR and between 4ba495b and 111ee44.

📒 Files selected for processing (19)
  • CHANGELOG.md
  • apps/api/src/routes/api-keys.ts
  • apps/api/src/routes/schedules.ts
  • apps/api/src/routes/trigger-inbound.ts
  • apps/api/src/routes/triggers.ts
  • apps/api/src/test/schedules.integration.test.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/worker/src/index.ts
  • docs/design/04-execution-runtime.md
  • docs/design/05-api-and-auth.md
  • packages/db/src/catalog.ts
  • packages/orchestration/src/queue.ts
  • packages/orchestration/src/resolve.ts
  • packages/orchestration/src/run-plan.ts
  • packages/orchestration/src/schedule-fire.ts
  • packages/orchestration/src/submit.ts
  • packages/orchestration/src/trigger-fire.ts
  • packages/orchestration/src/usage.ts
  • packages/orchestration/src/worker-executors.ts
🚧 Files skipped from review as they are similar to previous changes (11)
  • apps/api/src/routes/api-keys.ts
  • packages/orchestration/src/usage.ts
  • packages/orchestration/src/worker-executors.ts
  • apps/api/src/routes/triggers.ts
  • docs/design/04-execution-runtime.md
  • apps/api/src/routes/schedules.ts
  • packages/orchestration/src/run-plan.ts
  • apps/api/src/routes/trigger-inbound.ts
  • packages/orchestration/src/queue.ts
  • packages/orchestration/src/trigger-fire.ts
  • CHANGELOG.md

Comment thread docs/design/05-api-and-auth.md Outdated
Comment thread packages/db/src/catalog.ts Outdated
hutusi added 3 commits August 3, 2026 22:15
CodeRabbit was right about my own text. design/05 said an optional
`x-agrippa-delivery-id` "makes that idempotent outright" directly after
asserting a sender "can never" retry into a duplicate run — which reads as an
unconditional platform guarantee. It is not one. The dedupe key is
`(endpoint, external_id)` on a partial index, `external_id` comes from that
optional header, and a sender that omits it has nothing to match on: any retry
it makes, for any reason, inserts a second delivery and produces a second run.

What the platform guarantees without the sender's help is narrower and worth
stating separately: it acknowledges before submitting, so a slow submit cannot
itself be the timeout that provokes the retry. Both manual locales get the same
correction, since an integrator reads those rather than the design docs.

Also drops a `Db` import in packages/db/src/catalog.ts left unused when
`loadProviderCatalog` widened to `DbOrTx` in the round-2 commit.

581 pass / 5 skip / 0 fail; check and build green.
… loud repairs

Round 2's finding 1 offered two mechanisms — a unique firing key, or
committing the submission with its bookkeeping — and I implemented only
the second, treating the choice as settled. They close different
windows. The transaction covers the gap between the run insert and the
bookkeeping write; it says nothing about pg-boss re-delivering a job
whose transaction already committed, which after a worker dies mid-ack
arrives ~15 minutes later on the queue's defaults. `queue` then
submitted unconditionally, `replace` cancelled the run that same firing
had just created before submitting another, and `skip` only held while
the previous run was still running — which by then it usually is not.

Runs now carry `origin_key`, nullable with a partial unique index,
written inside the submission transaction: `schedule:<pg-boss job id>`
(stable across a retry, fresh per cron tick — the worker had the id in
hand and discarded it), `trigger:<delivery id>`, NULL for anything a
human submitted. `submitTaskIn` raises DuplicateFiringError, which
callers report as a skip, because fireSchedule routes anything else it
catches into a `schedule.failed` notification — paging a project
because the system refused to charge it twice is its own kind of
broken. Triggers already had this property through their delivery row's
`succeeded` flip; schedules had no per-firing record at all.

Calendar reconciliation, added in round 2, read the schedule rows
*before* the live calendar. Every writer commits its row first and
touches the calendar after, so that ordering raced the wrong way round:
a schedule created in the window was absent from the rows snapshot but
present in the calendar, and was torn out as an orphan seconds after
being created — enabled, no error, healthy in the UI, never firing. An
edit was worse: the reconciler compared the new entry against the old
row and re-registered the OLD cron, and nothing downstream re-validates
it, so the schedule genuinely fired at a time the UI said it did not.
The calendar is read first now, which makes the rows snapshot the
fresher of the two and every interleaving benign, and each unregister
re-validates against the row immediately before tearing an entry out.

The same function swallowed per-schedule failures into a bare `catch {}`
— no log, no counter, no rethrow — and the counters incremented after
the await, so a failure did not even show as a shortfall. If the only
drift was the schedule that kept failing, the sweeper logged nothing at
all, every tick, while the boot pass printed `+0/-0`: the exact line a
converged fleet prints. Both signals existed before the round-2 refactor
moved the loop into a package that takes no logger. Failures are now
returned, logged with the schedule id, counted against a restored `X/N`
denominator, and written to the amber `last_error` an admin already
reads on the row.

The webhook acknowledgement still awaited its enqueue.
`enqueueAfterCommit` bounds failure but never latency, and pg-boss
builds its own pool that waits indefinitely for a connection — so a
stalled database held the response open until Bun's 60s idleTimeout
killed the socket, and the sender saw a connection reset rather than a
status code. That is the most retry-triggering answer there is, and an
unkeyed retry inserts a second delivery and a second run while the
sweeper revives the first. It is no longer awaited; the durable row and
sweepTriggerDeliveries are the guarantee.

Delivery ids were truncated to 200 characters. The reachable half was
not the prefix collision but the empty string: Headers.get returns ""
for a present-but-empty header, "" is not null, and the dedupe index is
partial on IS NOT NULL — so every request carrying a blank
x-agrippa-delivery-id collapsed onto one delivery row forever, and once
that row succeeded the endpoint accepted webhooks and ran nothing,
answering 200 every time. Blank is now absent, and an over-long id is a
400: rewriting a sender's idempotency key into a shorter one turns two
distinct events into a duplicate. The test helper could not express the
failing case — `if (opts.deliveryId)` dropped an empty string — which
is why it survived two rounds.

Also clears the four noUnusedImports warnings left by the round-2
Db → DbOrTx widening, which `check` prints and then exits 0 on.

Verified against a real pg-boss cron tick as well as the suite: three
successive firings of a minutely schedule produced three runs with
three distinct job-id keys, and the restored boot line reads
"+0/-0 of N".
… place

Four defects found reviewing 41f8c7c, the one commit on this branch nobody
else had read. Three are mine, from that commit.

**A redelivery destroyed the occurrence's run (P1).** The origin-key check
lived inside the submission, which is downstream of the concurrency policy —
so `replace` reached its cancellation first, killed the run THIS firing had
created, and only then discovered the duplicate and returned without putting
anything back. The occurrence ended with a destroyed live run and no
replacement: strictly worse than the double charge the key exists to prevent,
and the opposite of what the docstring and the changelog claimed. The key is
now resolved at the top of fireSchedule, before anything observable happens;
the in-transaction check stays as the atomicity guard. The new test missed it
because it used `queue`, the one policy that never reaches the cancellation.

**The lastError meant to make repairs loud could never be cleared (P1).**
Nothing else clears that column for a schedule that is not firing — a
*disabled* one had no route at all, and PATCH only clears it when the body
happens to carry `enabled` — so the first transient calendar blip marked a
schedule broken in the UI permanently. Loud-forever is the silence it
replaced, reached from the other side. It now clears when the repair lands,
scoped by prefix so a real firing error is never cleared by a calendar pass.
Three more in the same seven lines: it reported unregister failures as
registration failures, issued an UPDATE per tick for orphan keys that have no
row, and re-stamped an unchanged message every minute on every replica —
dead tuples on a read-mostly table at exactly the moment an outage has made
every schedule fail at once.

**A real constraint violation still paged (P2).** DuplicateFiringError was
raised only by the pre-check, so a genuine 23505 — which two concurrent
invocations of one firing can still reach, since pg-boss expiry re-delivers a
job a live worker still holds — fell through to the failure path: on schedules
the raw constraint name in a `schedule.failed` notification, on triggers a
delivery flipped to `failed` behind a run that had succeeded, unclearable
because Retry refuses a delivery that already has a run id. Now classified at
the insert. Verified the field names against the driver rather than assuming:
bun-sql reports SQLSTATE in `errno` while `code` is its own tag, so checking
`code` alone would have made the whole branch dead.

**And the meta-pattern twice more.** Round 3 fixed the awaited enqueue at the
two inbound sites and left it on the front door: submitTask backs
`POST /projects/:id/tasks`, the first entry on the API-key allow-list, and the
one API-key-reachable mutation with no idempotency guard at all — same stall,
same connection reset, same retry, but a second task, run and charge with
nothing to dedupe against. Separately, sweepNotificationDeliveries — the
function sweepTriggerDeliveries was copied FROM — still lacks both guards the
copy was given in round 1, with the comment explaining why written into the
copy. A delivery whose POST is in flight could be stamped `failed`; the
success path overwrote it back, but in that window the UI offered Retry and a
click re-sent a duplicate webhook to the customer.

Smaller: origin_key no longer rides a `...run` spread into GET /runs/:id (a
queue identity is not an API surface), the 400 carries the header name and the
limit in `details` since the localized message wins over the constructed one,
the trigger duplicate branch writes a terminal status instead of leaving the
sweeper to exhaust it, `?limit=abc` no longer 500s, two ARCHITECTURE
invariants no longer share a number, and the schedule queue carries a note
that a dead-letter queue would break the invariant by minting a new job id.

Verified against a real pg-boss cron under `replace`: two ticks produced two
runs with two distinct job-id keys and the first correctly cancelled, while
the unit test proves a redelivery of one key leaves the run alive.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
apps/api/src/test/triggers.integration.test.ts (1)

844-857: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clear the race timer after the request settles.

The 2-second timer stays armed after Promise.race resolves. Bun keeps the event loop alive until it fires, so the suite pays the full delay. Store the handle and clear it in a finally.

♻️ Proposed change
-    const res = await Promise.race([
-      stalled.request(`/api/triggers/${trigger.token}`, {
-        method: "POST",
-        headers: {
-          "content-type": "application/json",
-          [TRIGGER_TIMESTAMP_HEADER]: ts,
-          [TRIGGER_SIGNATURE_HEADER]: `v1=${createHmac("sha256", SECRET)
-            .update(`${ts}.${raw}`)
-            .digest("hex")}`,
-        },
-        body: raw,
-      }),
-      new Promise<never>((_, reject) => setTimeout(() => reject(new Error("ack blocked")), 2_000)),
-    ]);
+    let timer: ReturnType<typeof setTimeout> | undefined;
+    let res: Response;
+    try {
+      res = await Promise.race([
+        stalled.request(`/api/triggers/${trigger.token}`, {
+          method: "POST",
+          headers: {
+            "content-type": "application/json",
+            [TRIGGER_TIMESTAMP_HEADER]: ts,
+            [TRIGGER_SIGNATURE_HEADER]: `v1=${createHmac("sha256", SECRET)
+              .update(`${ts}.${raw}`)
+              .digest("hex")}`,
+          },
+          body: raw,
+        }),
+        new Promise<never>((_, reject) => {
+          timer = setTimeout(() => reject(new Error("ack blocked")), 2_000);
+        }),
+      ]);
+    } finally {
+      clearTimeout(timer);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/test/triggers.integration.test.ts` around lines 844 - 857,
Update the Promise.race in the trigger request test to store the 2-second
setTimeout handle, then clear it in a finally block after the race settles,
preserving the existing timeout rejection and request behavior.
packages/orchestration/src/trigger-fire.ts (1)

187-190: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Use err.runId when the pre-check supplied it.

DuplicateFiringError carries runId whenever the sequential pre-check raised it. Only the index path leaves it null. The re-read is therefore redundant in the common case. Read only when err.runId is null.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/orchestration/src/trigger-fire.ts` around lines 187 - 190, Update
the DuplicateFiringError handling around the existing runs query to reuse
err.runId when it is already populated by the sequential pre-check. Only execute
the database re-read for the index path when err.runId is null, preserving the
existing lookup and result handling for that case.
packages/db/drizzle/0026_run_origin_key.sql (1)

2-2: 🩺 Stability & Availability | 🔵 Trivial

Plan for the index build lock on large runs tables.

CREATE UNIQUE INDEX without CONCURRENTLY takes an ACCESS EXCLUSIVE lock on runs and blocks reads and writes until the build finishes. The predicate limits the index to non-null origin_key, but Postgres still scans the whole table. If a deployment has a large runs table, schedule this migration in a maintenance window, or build the index out-of-band with CONCURRENTLY before running the migration. CONCURRENTLY cannot run inside a transaction, so it does not fit the transactional migration runner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/drizzle/0026_run_origin_key.sql` at line 2, Address the
runs_origin_key_uq index migration by scheduling its non-concurrent build in a
maintenance window or prebuilding it out-of-band with PostgreSQL’s concurrent
index operation before running the transactional migration; do not add
CONCURRENTLY to this migration, since the migration runner requires
transactional execution.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/design/04-execution-runtime.md`:
- Line 151: Update the inline code span on the affected documentation line to
remove any leading or trailing whitespace inside its backticks, while preserving
the surrounding wording and code content.

In `@docs/design/05-api-and-auth.md`:
- Line 21: Update the delivery guarantee wording around “exactly-once delivery”
to qualify it: a stable x-agrippa-delivery-id deduplicates sender retries into
one durable delivery and pg-boss job, but that delivery may still be retried or
fail. Preserve the existing at-least-once job-delivery and replay behavior
without claiming the delivery itself is guaranteed exactly once.

In `@docs/manual/zh-CN/04-administration.md`:
- Line 57: 在文档中定位包含 `x-agrippa-delivery-id` 和“超过 200
个字符”说明的句子,改写末尾截断规则,明确表示超长值会直接返回 `400`,不会被截断为可能产生标识冲突的值;保持其余去重和重试说明不变。

In `@packages/orchestration/src/schedule-fire.ts`:
- Around line 427-436: Update the clearing UPDATE in the healed-row flow to add
a lastError prefix predicate alongside the taskSchedules.id filter, using
CALENDAR_ERROR_PREFIX. Keep the existing healed calculation and null-field
updates unchanged so only rows whose current error still belongs to the
reconciler are cleared.

---

Nitpick comments:
In `@apps/api/src/test/triggers.integration.test.ts`:
- Around line 844-857: Update the Promise.race in the trigger request test to
store the 2-second setTimeout handle, then clear it in a finally block after the
race settles, preserving the existing timeout rejection and request behavior.

In `@packages/db/drizzle/0026_run_origin_key.sql`:
- Line 2: Address the runs_origin_key_uq index migration by scheduling its
non-concurrent build in a maintenance window or prebuilding it out-of-band with
PostgreSQL’s concurrent index operation before running the transactional
migration; do not add CONCURRENTLY to this migration, since the migration runner
requires transactional execution.

In `@packages/orchestration/src/trigger-fire.ts`:
- Around line 187-190: Update the DuplicateFiringError handling around the
existing runs query to reuse err.runId when it is already populated by the
sequential pre-check. Only execute the database re-read for the index path when
err.runId is null, preserving the existing lookup and result handling for that
case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d816e8b-5a85-4410-8760-1f3b80a19800

📥 Commits

Reviewing files that changed from the base of the PR and between 111ee44 and f021d3a.

📒 Files selected for processing (30)
  • ARCHITECTURE.md
  • CHANGELOG.md
  • apps/api/src/routes/execution.ts
  • apps/api/src/routes/trigger-inbound.ts
  • apps/api/src/routes/triggers.ts
  • apps/api/src/test/notifications.integration.test.ts
  • apps/api/src/test/schedules.integration.test.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/worker/src/index.ts
  • docs/design/01-domain-model.md
  • docs/design/04-execution-runtime.md
  • docs/design/05-api-and-auth.md
  • docs/manual/en/04-administration.md
  • docs/manual/zh-CN/04-administration.md
  • packages/core/src/triggers.ts
  • packages/db/drizzle/0026_run_origin_key.sql
  • packages/db/drizzle/meta/0026_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/catalog.ts
  • packages/db/src/schema/runs.ts
  • packages/i18n/locales/en/errors.json
  • packages/i18n/locales/zh-CN/errors.json
  • packages/orchestration/src/notifications.ts
  • packages/orchestration/src/queue.ts
  • packages/orchestration/src/run-plan.ts
  • packages/orchestration/src/schedule-fire.ts
  • packages/orchestration/src/submit.ts
  • packages/orchestration/src/trigger-fire.ts
  • packages/orchestration/src/usage.ts
  • packages/orchestration/src/worker-executors.ts
💤 Files with no reviewable changes (1)
  • packages/orchestration/src/usage.ts
🚧 Files skipped from review as they are similar to previous changes (15)
  • packages/i18n/locales/en/errors.json
  • packages/db/drizzle/meta/_journal.json
  • packages/i18n/locales/zh-CN/errors.json
  • packages/db/src/catalog.ts
  • ARCHITECTURE.md
  • apps/api/src/test/notifications.integration.test.ts
  • apps/api/src/routes/triggers.ts
  • packages/core/src/triggers.ts
  • packages/orchestration/src/run-plan.ts
  • CHANGELOG.md
  • packages/orchestration/src/worker-executors.ts
  • packages/orchestration/src/queue.ts
  • apps/worker/src/index.ts
  • apps/api/src/routes/trigger-inbound.ts
  • apps/api/src/routes/execution.ts

Comment thread docs/design/04-execution-runtime.md Outdated

The transaction closes the gap *between the two writes*; it says nothing about the queue re-delivering a job whose transaction already committed, which is a second and independent way to charge twice. **Every unattended firing therefore carries an idempotency key** — `runs.origin_key`, nullable with a partial unique index, set inside the submission transaction: schedules pass `schedule:<pg-boss job id>`, triggers pass `trigger:<delivery id>`, and the human paths pass nothing (a second click is a second intent). Both values are stable across a redelivery and distinct between firings — pg-boss preserves a job's id when it re-inserts a retried job and mints a new one per cron tick — so "this run exists" and "this firing already happened" become one committed fact. The key is resolved **before the concurrency policy**, not at the submission: everything below that point has side effects and `replace`'s is destructive, so a redelivery discovered late would cancel the run its own firing created and then decline to replace it. `submitTaskIn` raises `DuplicateFiringError`, which callers report as a skip rather than a failure: `fireSchedule` routes anything else it catches into a `schedule.failed` notification, and paging a project because the system correctly refused to charge it twice is its own kind of broken. Schedules needed this most — a trigger already had a per-firing row whose `succeeded` flip commits with the run, while a schedule's `lastRunId` is a pointer overwritten each firing, and the liveness check that read it only held while the previous run was still running, which at pg-boss's ~15-minute redelivery latency it usually is not.

The calendar has its own convergence: a `schedule-calendar` sweeper stage diffs `task_schedules` against `boss.getSchedules`, so a registration dropped at request time is repaired within a tick rather than at the next worker restart, and an entry whose row was deleted is removed. **The calendar is read before the rows**, and the order is load-bearing: the two stores have no transaction spanning them, and every writer commits its row before touching the calendar, so reading the calendar first makes the rows snapshot the fresher of the two and turns every interleaving benign — a schedule created mid-reconcile is registered rather than mistaken for an orphan and torn out, and an edit converges onto the new cron instead of having the old one restored under it. What that buys is convergence within a tick, not mutual exclusion, which no read order can give across two stores; the remaining window is closed for the one repair that can silence a live schedule by re-validating the row immediately before each unregister. A schedule the reconciler cannot repair is **named** — returned to the worker, logged with its id, and written to the same `last_error` an admin already reads on the row — because a repair loop that tolerates failure silently is indistinguishable from one that succeeded, and the schedule stays unregistered while the drift line reads `+0/-0`. That message is prefixed `calendar `, and **the reconciler clears it once the repair lands**: nothing else can, for a schedule that is not firing, so without it one transient blip marks a schedule broken in the UI permanently — loud-forever being the same failure as silence, reached from the other side. The write is guarded against re-stamping an unchanged message, since it runs every minute on every replica.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove whitespace inside inline code spans.

markdownlint reports MD038 on Line 151. Remove leading or trailing spaces inside the affected backtick span.

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 151-151: Spaces inside code span elements

(MD038, no-space-in-code)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/04-execution-runtime.md` at line 151, Update the inline code span
on the affected documentation line to remove any leading or trailing whitespace
inside its backticks, while preserving the surrounding wording and code content.

Source: Linters/SAST tools

Comment thread docs/design/05-api-and-auth.md Outdated
Comment thread docs/manual/zh-CN/04-administration.md Outdated
Comment thread packages/orchestration/src/schedule-fire.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
docs/design/05-api-and-auth.md (1)

21-21: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not claim exactly-once delivery.

A stable x-agrippa-delivery-id deduplicates repeat submissions into one durable delivery and job. The delivery can still be retried or fail because queue processing is at least once. Replace “gets exactly-once delivery” with “gets retry-safe deduplication” or equivalent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/05-api-and-auth.md` at line 21, Update the delivery guarantee
wording in the API/auth design text: replace the claim that a stable
x-agrippa-delivery-id provides “exactly-once delivery” with wording that
accurately describes retry-safe deduplication, while preserving the existing
explanation of durable delivery records and at-least-once queue processing.
docs/manual/zh-CN/04-administration.md (1)

57-57: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

请明确说明超长标识不会被截断。

不会被截断成可能撞车的另一个值 表述不够准确。建议改为 而不会被截断为可能与其他事件冲突的值,以明确说明超长值会直接返回 400,不会被截断为冲突标识。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/manual/zh-CN/04-administration.md` at line 57, 修改中文文档中关于超过 200 个字符的
x-agrippa-delivery-id 的表述,明确说明该值会直接返回 400,且不会被截断为可能与其他事件冲突的标识;保留其余去重和重试说明不变。

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@docs/design/05-api-and-auth.md`:
- Line 21: Update the delivery guarantee wording in the API/auth design text:
replace the claim that a stable x-agrippa-delivery-id provides “exactly-once
delivery” with wording that accurately describes retry-safe deduplication, while
preserving the existing explanation of durable delivery records and
at-least-once queue processing.

In `@docs/manual/zh-CN/04-administration.md`:
- Line 57: 修改中文文档中关于超过 200 个字符的 x-agrippa-delivery-id 的表述,明确说明该值会直接返回
400,且不会被截断为可能与其他事件冲突的标识;保留其余去重和重试说明不变。

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b94c77b4-23ff-494e-a622-9df9aa262203

📥 Commits

Reviewing files that changed from the base of the PR and between 111ee44 and f021d3a.

📒 Files selected for processing (30)
  • ARCHITECTURE.md
  • CHANGELOG.md
  • apps/api/src/routes/execution.ts
  • apps/api/src/routes/trigger-inbound.ts
  • apps/api/src/routes/triggers.ts
  • apps/api/src/test/notifications.integration.test.ts
  • apps/api/src/test/schedules.integration.test.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/worker/src/index.ts
  • docs/design/01-domain-model.md
  • docs/design/04-execution-runtime.md
  • docs/design/05-api-and-auth.md
  • docs/manual/en/04-administration.md
  • docs/manual/zh-CN/04-administration.md
  • packages/core/src/triggers.ts
  • packages/db/drizzle/0026_run_origin_key.sql
  • packages/db/drizzle/meta/0026_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/catalog.ts
  • packages/db/src/schema/runs.ts
  • packages/i18n/locales/en/errors.json
  • packages/i18n/locales/zh-CN/errors.json
  • packages/orchestration/src/notifications.ts
  • packages/orchestration/src/queue.ts
  • packages/orchestration/src/run-plan.ts
  • packages/orchestration/src/schedule-fire.ts
  • packages/orchestration/src/submit.ts
  • packages/orchestration/src/trigger-fire.ts
  • packages/orchestration/src/usage.ts
  • packages/orchestration/src/worker-executors.ts
💤 Files with no reviewable changes (1)
  • packages/orchestration/src/usage.ts
🚧 Files skipped from review as they are similar to previous changes (25)
  • packages/db/src/schema/runs.ts
  • apps/api/src/test/notifications.integration.test.ts
  • packages/db/drizzle/0026_run_origin_key.sql
  • packages/db/drizzle/meta/_journal.json
  • packages/orchestration/src/run-plan.ts
  • packages/orchestration/src/trigger-fire.ts
  • packages/core/src/triggers.ts
  • packages/i18n/locales/en/errors.json
  • apps/api/src/routes/triggers.ts
  • ARCHITECTURE.md
  • packages/orchestration/src/queue.ts
  • apps/api/src/routes/execution.ts
  • packages/orchestration/src/worker-executors.ts
  • CHANGELOG.md
  • packages/orchestration/src/notifications.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/worker/src/index.ts
  • packages/i18n/locales/zh-CN/errors.json
  • packages/orchestration/src/schedule-fire.ts
  • docs/design/01-domain-model.md
  • packages/db/drizzle/meta/0026_snapshot.json
  • packages/orchestration/src/submit.ts
  • apps/api/src/test/schedules.integration.test.ts
  • apps/api/src/routes/trigger-inbound.ts
  • packages/db/src/catalog.ts

hutusi added 2 commits August 4, 2026 08:37
CI was green and both reviewers had signed off on everything except two
concurrency findings. Triaging CodeRabbit's older unresolved threads while
verifying those turned up something neither report led with.

**Every schedule and trigger notification shipped with an empty body.**
deliverNotification sourced its template variables from `run_events`, but a
project-scoped event — a schedule or trigger that stopped — has no run to hang
an event row on, so insertProjectEventDeliveries puts the payload on the
delivery row instead, and nothing ever read it. The card an operator received
read, verbatim: `The schedule "" in Acme produced no run this time: .` That is
the only channel that reaches a human when unattended work breaks, and it is
the argument this branch has made in three rounds of docs, comments and both
manuals. It reached them with nothing in it. Two bugs in one path: the payload
never arrived, and `error` is emitted as a formatted string while the renderer
cast it to {code, message}, so a truthy string took the object branch and
rendered empty even when the payload was there. Disabled reasons interpolated
as raw enums into prose; they now resolve through the same catalog the
settings page uses.

**An infrastructure fault spent the occurrence.** fireSchedule and fireTrigger
classified only DuplicateFiringError and funnelled everything else — deadlocks,
statement timeouts, plain bugs — into the record-and-announce path, which
returns rather than throws. The pg-boss job then completed, so a weekly
schedule lost a week to a transient error and the project was told it was at
fault. Only AppError and SubmitError are the project's problem now; the rest
escapes so pg-boss retries, which is what AGENTS.md says the engine does and
what the worker's comment at the call site already claimed happened here. An
existing test pinned the old behaviour and changes with it.

**The two races codex found, one of which CodeRabbit found independently with
the same fix.** The healing clear tested ownership in JavaScript against the
rows snapshot but filtered the UPDATE by id alone — so a genuine firing error
committed during the reconcile was wiped, and the row rendered healthy until
its next failure. Ownership is now a SQL predicate evaluated under the row
lock, with the id list kept: a bare prefix match would erase the failures the
same tick just recorded. And the register leg wrote cron/timezone straight
from that snapshot while both unregister legs re-validated first — but the
snapshot is taken once and the loop is serial with a round trip per repair, so
a row late in the list acted on a reading many seconds old, and an edit landing
in the gap had its old cron restored under it, which nothing downstream
re-validates. Every repair now re-reads before writing. Neither mistake is
durably worse than the other and both self-heal within a tick, so the
docstring's justification for guarding one leg was wrong and is rewritten —
along with the sentence claiming the clear was already scoped by prefix, which
was the sharper defect, being what a reader would trust instead of re-deriving
the race.

The 60s sweeper also gained a re-entrancy guard: setInterval does not await its
callback, so a slow sweep could overlap itself and run two reconciles that
invalidate each other's snapshots. The worker logs the outcome's error/reason
rather than just its discriminant — a lost firing used to print "failed" with
no cause.

Also: `Bearer` is matched case-insensitively per RFC 9110 (a lowercase scheme
fell through to the session branch and answered a bare `unauthorized`), with
the daemon router's duplicate parse folded into the shared helper that calls
itself the one implementation; the exactly-once claim in design/05 qualified to
the at-most-once it actually is; the stale Governance api-keys block corrected;
MD038 and a colloquial zh-CN phrase fixed.

Verified against a real pg-boss cron as well as the suite: a minutely schedule
registered, fired on a genuine job id, and left no spurious calendar error.

Dismissed as verified non-defects: the retry attribution (c.var.user is
populated for API-key callers from the key's owner), the cron error message,
and the junk Content-Length test.
Six findings reviewing the previous commit, which nobody else had read.
Four are mine, from that commit. Two of them matter.

**Making unexpected errors propagate left the persistent case silent.**
Retrying beats recording only while retries remain. Past the limit the job
rests in `failed` — and pg-boss does not emit on handler failure, and no
dead-letter queue is configured (deliberately: redrive would mint a new job
id and therefore a second origin_key). So a permanent error produced no
lastError, no schedule.failed, no log line, and a UI showing the schedule
healthy. I traded a wrong-but-visible outcome for an invisible one, which is
the exact failure this feature exists to prevent, while fixing its opposite.
Both firings now take the last attempt as a signal to record instead — the
shape the notification handler thirty lines above already used. That also
covers the permanent-but-untyped cases the AppError/SubmitError split misses:
a template row that cannot be upgraded, params that fail a bare property read.
The commit body's own list conflated transient faults with plain bugs; only
the first deserved to escape.

**The localized reason doubled the sentence.** It resolved through the
settings catalog, whose strings stand alone as a status line, so the card read
"was disabled and will not run again: Stopped: its owner no longer has access
to this project.." — doubled clause, doubled terminator, in all four
renderings and both locales. The notifications namespace gets its own
clause-shaped wording; the settings copy is left alone. Two surfaces, two
grammars.

**And my "exhaustive" audit of the payload column missed a statement.** The
dual-purpose read is safe because only the success path writes the snapshot
and a succeeded delivery is never re-delivered — but that rests on a succeeded
row never becoming `failed`, and the endpoint-disabled write had no
`status = 'pending'` guard and ran before the claim. A duplicate job could
stamp `failed` over a delivery that had just succeeded, and the retry would
re-render from the request snapshot. Guarded now, like its sibling.

**Two tests proved less than they appeared to.** The cron-race test committed
its edit *before* calling the reconciler, so the snapshot already held the new
value — it passed against the unfixed code. It now commits from inside a
previous row's repair, which is the only way into that window; verified by
reverting the fix and watching it fail. And the notification end-to-end
asserted on the raw request body, which the generic formatter fills with the
payload verbatim — so it passed on the echo even if the rendering were empty.
It now asserts the rendered message, with an exact string, which is what
catches the doubling above. Nothing pinned the reason catalog against the
enums either; a fourth reason would have shipped as a raw code with a green
suite.

Smaller: the wrong-verb defect fixed in round 4 came back narrower, because
the refactor moved the re-read ahead of the branch that sets the label —
seeded from the snapshot now. The sweeper's re-entrancy guard was unbounded,
and nothing on that path has a timeout, so one hung call would have disabled
every recovery mechanism until restart — the same argument the per-stage
isolation makes, one level up; it now force-releases past a deadline and says
so at error level. outcomeDetail dropped the run id on the success branch and
is typed as the two unions so the next omission fails to compile. Three
docstrings that described retired rules: the notifications schema (retries
"re-render from the durable event" — project-scoped ones have none),
currentState ("before an unregister" — it now guards every repair), and
design/04's two-way split, which is three-way.

Verified: 601 pass, 5 skip, 0 fail.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/i18n/src/index.ts (1)

93-98: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse notificationMessages and drop the structural cast.

Line 94 repeats the locale resolution already implemented at lines 71-73 and casts the catalog to a hand-written shape. The cast removes type checking on reasons, so a locale file that loses the key compiles and renders the raw enum code. Calling notificationMessages(locale) keeps one locale rule and lets the JSON-derived NotificationCatalog type describe the field.

♻️ Proposed refactor
 export function disabledReasonText(reason: string, locale: string): string {
-  const table = (locale.startsWith("zh") ? resources["zh-CN"] : resources.en).notifications as {
-    reasons?: Record<string, string>;
-  };
-  return table.reasons?.[reason] ?? reason;
+  const { reasons } = notificationMessages(locale) as NotificationCatalog & {
+    reasons?: Record<string, string>;
+  };
+  return reasons?.[reason] ?? reason;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/i18n/src/index.ts` around lines 93 - 98, Update disabledReasonText
to obtain the notification catalog through notificationMessages(locale) instead
of resolving resources directly. Remove the handwritten structural cast and read
reasons from the typed NotificationCatalog result, preserving the existing raw
reason fallback when no translation exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/i18n/src/index.ts`:
- Around line 93-98: Update disabledReasonText to obtain the notification
catalog through notificationMessages(locale) instead of resolving resources
directly. Remove the handwritten structural cast and read reasons from the typed
NotificationCatalog result, preserving the existing raw reason fallback when no
translation exists.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c1229960-a3c8-4147-b078-919b253e9a84

📥 Commits

Reviewing files that changed from the base of the PR and between f021d3a and 733fe19.

📒 Files selected for processing (18)
  • CHANGELOG.md
  • apps/api/src/lib/bearer-tokens.ts
  • apps/api/src/routes/daemon.ts
  • apps/api/src/test/api-keys.integration.test.ts
  • apps/api/src/test/schedules.integration.test.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/worker/src/deps/notify.test.ts
  • apps/worker/src/deps/notify.ts
  • apps/worker/src/index.ts
  • docs/design/04-execution-runtime.md
  • docs/design/05-api-and-auth.md
  • docs/manual/zh-CN/04-administration.md
  • packages/db/src/schema/notifications.ts
  • packages/i18n/locales/en/notifications.json
  • packages/i18n/locales/zh-CN/notifications.json
  • packages/i18n/src/index.ts
  • packages/orchestration/src/schedule-fire.ts
  • packages/orchestration/src/trigger-fire.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/i18n/locales/zh-CN/notifications.json
  • packages/i18n/locales/en/notifications.json
  • apps/api/src/lib/bearer-tokens.ts
  • CHANGELOG.md
  • docs/design/04-execution-runtime.md
  • apps/api/src/test/api-keys.integration.test.ts
  • apps/api/src/test/triggers.integration.test.ts
  • apps/api/src/test/schedules.integration.test.ts
  • packages/orchestration/src/trigger-fire.ts

@hutusi
hutusi merged commit 0fd2eaf into main Aug 4, 2026
5 checks passed
@hutusi
hutusi deleted the feat/m2-triggers branch August 5, 2026 08:27
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