Skip to content

fix: ignore invalid run IDs in activity logging#798

Open
brew-agent wants to merge 2 commits intopaperclipai:masterfrom
brew-agent:fix/activity-log-invalid-run-id
Open

fix: ignore invalid run IDs in activity logging#798
brew-agent wants to merge 2 commits intopaperclipai:masterfrom
brew-agent:fix/activity-log-invalid-run-id

Conversation

@brew-agent
Copy link
Copy Markdown

@brew-agent brew-agent commented Mar 13, 2026

Summary

  • validate claimed runId values in actorMiddleware before route code runs
  • only keep runId on agent actors when it matches a real heartbeat_runs row for the same agent and company
  • drop runId for board/session requests and degrade to undefined on lookup errors
  • keep logActivity simple again and add focused tests around actor run-id handling

Why

Some API requests were carrying stale or fabricated run IDs via x-paperclip-run-id or agent auth context. Those values flowed into activity_log.run_id, violated the FK to heartbeat_runs, and turned otherwise-successful mutations into 500 responses.

This version fixes the problem at the request-auth boundary instead of inside logActivity. That keeps invalid run IDs out of downstream route handling entirely, preserves valid run linkage, and avoids adding an extra lookup inside every logActivity call.

Before inserting into activity_log, validate that the supplied
run_id actually exists in heartbeat_runs. If the run is not found
(e.g. a stale/nonexistent ID from the x-paperclip-run-id header),
fall back to null so the insert succeeds instead of throwing a
foreign-key violation 500.

Affects all routes that call logActivity with actor.runId, including
PATCH /api/issues/:id and many others.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR fixes FK constraint violations in activity_log by introducing a resolveRunId helper that validates a caller-supplied run_id against heartbeat_runs before the insert, nulling it out when the run doesn't exist. The approach is conceptually sound and directly addresses the 500 errors described, but there is one notable gap that partially undermines the stated goal of graceful degradation.

  • Unhandled errors in resolveRunId: The lookup query has no try/catch. A transient DB error (timeout, lock, etc.) will propagate through logActivity and still result in a 500 for the caller — now from the validation step rather than the insert. Since the desired fallback is already null, wrapping the query in a try/catch and returning null on failure would complete the graceful-degradation intent.
  • Extra DB round-trip per call: Every logActivity invocation with a non-null runId now issues an additional SELECT against heartbeat_runs. If this function is called on every issue mutation, the performance impact on the hot path is worth evaluating.
  • Test quality: The inserted array in createDbStub is populated but never used in assertions; tests also lack coverage for undefined runId and for the error-propagation path in resolveRunId.

Confidence Score: 3/5

  • Safe to merge for the FK-violation fix, but the missing error handling in resolveRunId means the 500-prevention is incomplete and should be addressed.
  • The core logic is correct and the FK violation is fixed for the happy path. The main concern is that resolveRunId doesn't handle DB errors, which partially contradicts the stated goal of graceful degradation and can still surface 500s via a different code path. The performance overhead of the extra SELECT is a secondary concern. Neither issue is a blocking regression, but both are worth addressing before considering the PR fully resolved.
  • Pay close attention to server/src/services/activity-log.ts — specifically the lack of error handling in resolveRunId.

Important Files Changed

Filename Overview
server/src/services/activity-log.ts Adds resolveRunId to validate run IDs against heartbeat_runs before insert, fixing FK 500s — but lacks error handling so a DB error in the lookup still propagates as a 500, and adds an extra round-trip for every call with a non-null runId.
server/src/tests/activity-log-invalid-run-id.test.ts New test file covering invalid, valid, and null runId cases with a hand-rolled DB stub; covers the core scenarios but has an unused inserted array and is missing a test for undefined runId and for DB error propagation from resolveRunId.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: server/src/services/activity-log.ts
Line: 20-28

Comment:
**`resolveRunId` propagates DB errors, reintroducing 500s**

The PR's stated goal is to "gracefully null out stale or invalid run ids instead of throwing FK 500s," but `resolveRunId` has no error handling. If the `heartbeat_runs` query throws (e.g., connection timeout, table lock, transient network error), the exception propagates through `logActivity` and the caller still gets a 500 — now from the lookup rather than the insert.

Since the fallback behavior for an unresolvable `runId` is already `null`, wrapping the query in a try/catch and returning `null` on failure would fully honor the graceful-degradation intent:

```ts
async function resolveRunId(db: Db, runId: string | null | undefined): Promise<string | null> {
  if (!runId) return null;
  try {
    const row = await db
      .select({ id: heartbeatRuns.id })
      .from(heartbeatRuns)
      .where(eq(heartbeatRuns.id, runId))
      .then((rows) => rows[0] ?? null);
    return row ? runId : null;
  } catch {
    return null;
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: server/src/services/activity-log.ts
Line: 33

Comment:
**Extra DB round-trip on every `logActivity` call with a `runId`**

`resolveRunId` is now called unconditionally before every insert, meaning any caller that supplies a `runId` pays an additional SELECT against `heartbeat_runs`. If `logActivity` is invoked on every issue mutation (which the PR description implies), this doubles the DB traffic for those paths and adds latency in the hot path.

A few alternatives worth considering:
- Pass the validated `runId` in from the call-site (at the point where the run is started, the ID is already known to be valid).
- Cache the lookup for the duration of a request, since a single request is unlikely to reference more than one run.
- Accept the overhead if `logActivity` is low-frequency, but document the trade-off explicitly.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: server/src/__tests__/activity-log-invalid-run-id.test.ts
Line: 10-28

Comment:
**`inserted` array is collected but never asserted upon**

`createDbStub` populates an `inserted` array via the stub `values` mock, but all three tests assert only on `values.mock.calls`. The `inserted` return value is destructured in the first test but then unused — all tests end up going through `values.mock.calls[0][0]` directly. The `inserted` array and its population inside `values` can be removed to reduce noise, or assertions should be switched to use it consistently.

Also, no test covers the case where `runId` is `undefined` (i.e., the field is omitted from the input entirely). While `!runId` handles `undefined` the same as `null`, an explicit test for this path would document that contract and guard against future changes to the guard condition.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 0a8a5c7

Comment thread server/src/services/activity-log.ts Outdated
Comment on lines +20 to +28
async function resolveRunId(db: Db, runId: string | null | undefined): Promise<string | null> {
if (!runId) return null;
const row = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
return row ? runId : null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

resolveRunId propagates DB errors, reintroducing 500s

The PR's stated goal is to "gracefully null out stale or invalid run ids instead of throwing FK 500s," but resolveRunId has no error handling. If the heartbeat_runs query throws (e.g., connection timeout, table lock, transient network error), the exception propagates through logActivity and the caller still gets a 500 — now from the lookup rather than the insert.

Since the fallback behavior for an unresolvable runId is already null, wrapping the query in a try/catch and returning null on failure would fully honor the graceful-degradation intent:

async function resolveRunId(db: Db, runId: string | null | undefined): Promise<string | null> {
  if (!runId) return null;
  try {
    const row = await db
      .select({ id: heartbeatRuns.id })
      .from(heartbeatRuns)
      .where(eq(heartbeatRuns.id, runId))
      .then((rows) => rows[0] ?? null);
    return row ? runId : null;
  } catch {
    return null;
  }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/activity-log.ts
Line: 20-28

Comment:
**`resolveRunId` propagates DB errors, reintroducing 500s**

The PR's stated goal is to "gracefully null out stale or invalid run ids instead of throwing FK 500s," but `resolveRunId` has no error handling. If the `heartbeat_runs` query throws (e.g., connection timeout, table lock, transient network error), the exception propagates through `logActivity` and the caller still gets a 500 — now from the lookup rather than the insert.

Since the fallback behavior for an unresolvable `runId` is already `null`, wrapping the query in a try/catch and returning `null` on failure would fully honor the graceful-degradation intent:

```ts
async function resolveRunId(db: Db, runId: string | null | undefined): Promise<string | null> {
  if (!runId) return null;
  try {
    const row = await db
      .select({ id: heartbeatRuns.id })
      .from(heartbeatRuns)
      .where(eq(heartbeatRuns.id, runId))
      .then((rows) => rows[0] ?? null);
    return row ? runId : null;
  } catch {
    return null;
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread server/src/services/activity-log.ts Outdated
export async function logActivity(db: Db, input: LogActivityInput) {
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
const resolvedRunId = await resolveRunId(db, input.runId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Extra DB round-trip on every logActivity call with a runId

resolveRunId is now called unconditionally before every insert, meaning any caller that supplies a runId pays an additional SELECT against heartbeat_runs. If logActivity is invoked on every issue mutation (which the PR description implies), this doubles the DB traffic for those paths and adds latency in the hot path.

A few alternatives worth considering:

  • Pass the validated runId in from the call-site (at the point where the run is started, the ID is already known to be valid).
  • Cache the lookup for the duration of a request, since a single request is unlikely to reference more than one run.
  • Accept the overhead if logActivity is low-frequency, but document the trade-off explicitly.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/activity-log.ts
Line: 33

Comment:
**Extra DB round-trip on every `logActivity` call with a `runId`**

`resolveRunId` is now called unconditionally before every insert, meaning any caller that supplies a `runId` pays an additional SELECT against `heartbeat_runs`. If `logActivity` is invoked on every issue mutation (which the PR description implies), this doubles the DB traffic for those paths and adds latency in the hot path.

A few alternatives worth considering:
- Pass the validated `runId` in from the call-site (at the point where the run is started, the ID is already known to be valid).
- Cache the lookup for the duration of a request, since a single request is unlikely to reference more than one run.
- Accept the overhead if `logActivity` is low-frequency, but document the trade-off explicitly.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +10 to +28
function createDbStub(runExists: boolean) {
const inserted: unknown[] = [];

// Chain for select().from().where().then()
const thenFn = vi.fn((cb: (rows: unknown[]) => unknown) =>
Promise.resolve(cb(runExists ? [{ id: "run-1" }] : [])),
);
const where = vi.fn(() => ({ then: thenFn }));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));

// Chain for insert().values()
const values = vi.fn(async (row: unknown) => {
inserted.push(row);
});
const insert = vi.fn(() => ({ values }));

return { db: { select, insert } as any, inserted, values };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

inserted array is collected but never asserted upon

createDbStub populates an inserted array via the stub values mock, but all three tests assert only on values.mock.calls. The inserted return value is destructured in the first test but then unused — all tests end up going through values.mock.calls[0][0] directly. The inserted array and its population inside values can be removed to reduce noise, or assertions should be switched to use it consistently.

Also, no test covers the case where runId is undefined (i.e., the field is omitted from the input entirely). While !runId handles undefined the same as null, an explicit test for this path would document that contract and guard against future changes to the guard condition.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/__tests__/activity-log-invalid-run-id.test.ts
Line: 10-28

Comment:
**`inserted` array is collected but never asserted upon**

`createDbStub` populates an `inserted` array via the stub `values` mock, but all three tests assert only on `values.mock.calls`. The `inserted` return value is destructured in the first test but then unused — all tests end up going through `values.mock.calls[0][0]` directly. The `inserted` array and its population inside `values` can be removed to reduce noise, or assertions should be switched to use it consistently.

Also, no test covers the case where `runId` is `undefined` (i.e., the field is omitted from the input entirely). While `!runId` handles `undefined` the same as `null`, an explicit test for this path would document that contract and guard against future changes to the guard condition.

How can I resolve this? If you propose a fix, please make it concise.

Move the invalid-run-id guard out of logActivity and into actorMiddleware
so downstream code only ever sees a verified run id:

- board/unauthenticated actors never carry a runId (header is ignored)
- agent actors (JWT and API key) validate the claimed runId against
  heartbeat_runs matching both agentId and companyId; best-effort:
  DB errors fall back to undefined instead of breaking the request
- logActivity is simplified back to a straight insert (no lookup)

Tests updated: old activity-log unit test replaced with a pass-through
smoke test; new actor-middleware-run-id.test.ts covers all four
resolution branches and the board-actor exclusion.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
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