Skip to content

feat(AP-24): artifact pinning and revision retention#104

Merged
isuttell merged 5 commits into
mainfrom
cursor/pinning-revision-retention-3962
May 27, 2026
Merged

feat(AP-24): artifact pinning and revision retention#104
isuttell merged 5 commits into
mainfrom
cursor/pinning-revision-retention-3962

Conversation

@isuttell

@isuttell isuttell commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements Phase 4 pinning and non-current revision retention.

Base: origin/main at 1ad2436 (AP-23 merged).

Pinning

  • artifacts.pinned_at + migration 0013_pinning_and_revision_retention.sql
  • Dashboard-only POST /v1/web/artifacts/{id}/pin and /unpin (50/workspace cap)
  • Jobs auto-deletion sweep excludes pinned_at is not null

Revision retention

  • workspaces.revision_retention_days (nullable)
  • Jobs retention cron: retain → audit → rd: denylist → revision-scoped byte purge

Tests / coverage

  • Added focused coverage for revision denylist/purge, retention replay, web transforms, pin cap, and pin/unpin edge cases
  • pnpm test:coverage branch coverage 80.18% (was 78.84%)
  • pnpm verify (76 tasks)

Deploy / hosted

Linear Issue: AP-24

Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Added authenticated POST endpoints to pin/unpin web artifacts; pinned artifacts are exempt from auto-deletion and subject to a per-workspace pin cap (409 error when exceeded).
    • Workspace setting revision_retention_days enables automatic retention of old published revisions: writes denylist entries, enqueues revision-scoped byte-purge work, and marks retained revisions.
  • Documentation

    • Project status, changelog, coverage, and implementation docs updated to reflect pinning and revision retention.

Review Change Stack

Add pinned_at on artifacts and revision_retention_days on workspaces.
Dashboard members can pin/unpin via web API; pinned artifacts skip auto
deletion. Jobs retention cron marks non-current revisions retained, writes
rd: denylist entries, and enqueues revision-scoped byte purge.

Co-authored-by: Isaac Suttell <isaac@isaacsuttell.com>
@linear-code

linear-code Bot commented May 27, 2026

Copy link
Copy Markdown
AP-24 Phase 4: Add pinning and non-current Revision retention

Context

Phase 4 needs Pinned Artifacts, non-current Revision retention, and auto-deletion behavior that respects pinning.

Source docs

  • docs/ops/status/phase-backlog.md
  • docs/adr/0048-transient-artifacts-by-default.md
  • CONTEXT.md

Scope

Add pinning state, retention policy for non-current Revisions, and auto-deletion behavior that respects Pinned Artifacts.

Out of scope

Do not add billing plan-specific pinning limits unless a billing ticket has already provided the entitlement seam.

Dependencies

Blocked by jobs queue topology for automated retention enforcement.

Implementation notes

Use Pinned Artifact, Retention, and Auto Deletion glossary terms. Ensure deletion/purge paths respect pinning consistently.

Acceptance

Pinned Artifacts are exempt from Auto Deletion, non-current Revisions are retained/deleted according to policy, and tests cover pinned/unpinned cases.

Verification

Run DB/API/jobs tests and pnpm verify.

Remote Cursor handoff

Start by reading AGENTS.md, then docs/agents/remote-cursor-agent.md, then this issue. Fetch current repo status from docs/ops/project-status.md and relevant ADR/spec docs named above. Keep the change scoped to this issue. Run the ticket-specific verification plus pnpm verify unless the issue explicitly says a narrower check is acceptable. Do not run hosted production deploys or smokes unless the issue explicitly grants credentials and approval.

Review in Linear

@isuttell isuttell temporarily deployed to pr-preview-104 May 27, 2026 05:27 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 07c97327-311d-4f65-986a-ff697c87ac76

📥 Commits

Reviewing files that changed from the base of the PR and between fed99e4 and 155dacb.

📒 Files selected for processing (13)
  • apps/api/src/index.test.ts
  • apps/api/src/index.ts
  • apps/jobs/README.md
  • apps/jobs/src/discovery/retention.ts
  • apps/jobs/src/lifecycle.test.ts
  • docs/ops/status/implementation.md
  • docs/ops/status/phase-backlog.md
  • packages/db/src/index.test.ts
  • packages/db/src/queries/artifacts.ts
  • packages/db/src/repository/core.ts
  • packages/db/src/repository/local-entities.ts
  • packages/db/src/repository/ports.ts
  • packages/db/src/repository/postgres-entities.ts

Walkthrough

This PR implements artifact pinning and non-current revision retention. It adds DB schema/migration and types (artifacts.pinned_at, workspaces.revision_retention_days), a PINNED_ARTIFACT_CAP config, new artifact pin/unpin repository methods and query helpers, web API routes/handlers and OpenAPI/error enums, retention discovery job and purge-side-effect helpers (denylist + revision-scoped byte purge enqueue), plus tests and docs updates.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API as Web API
  participant Repo as Repository
  participant DB as Database
  Client->>API: POST /v1/web/artifacts/{id}/pin (Idempotency-Key)
  API->>API: validate member principal, idempotency
  API->>Repo: pinWebArtifact(actor,idempotencyKey,artifactId)
  Repo->>DB: tryPinUnderCap / setPinnedAt
  DB-->>Repo: pinned / cap_exceeded / not_found
  Repo-->>API: WebArtifactDetail or error
  API-->>Client: 200 WebArtifactDetail or 409 pinned_artifact_cap_exceeded
Loading
sequenceDiagram
  participant Cron
  participant Jobs as runRetentionDiscovery
  participant Cmd as lifecycle.revision.retain
  participant KV as DENYLIST
  participant Queue as BYTE_PURGE_QUEUE
  Cron->>Jobs: invoke runRetentionDiscovery(env, now)
  Jobs->>DB: query eligible non-current published revisions
  DB-->>Jobs: retentionCandidates[]
  loop per candidate
    Jobs->>KV: writeRevisionDenylist(rd:<revisionId>)
    KV-->>Jobs: ok / fail
    alt denylist ok
      Jobs->>Queue: enqueueRevisionBytePurge(message with revision prefix)
      Queue-->>Jobs: queued
      Jobs->>Cmd: runCommand(systemActor, "lifecycle.revision.retain", revisionId)
      Cmd-->>Jobs: retained? / replay?
    end
  end
  Jobs-->>Cron: report discovered/enqueued/cap_hit
Loading

Possibly related issues

  • AP-24: Phase 4: Add pinning and non-current Revision retention — this PR implements the pinning state, cap enforcement, auto-deletion exemption, and retention discovery described by the issue.

Possibly related PRs

  • zaks-io/agent-paste#4: Shares idempotent mutation wiring patterns used by the new pin/unpin handlers.
  • zaks-io/agent-paste#79: Related API error mapping and revision lifecycle work that intersects with mapRepositoryError and retention flows.

"I hop through code and count each pin,
a carrot taped to artifacts kept within.
Revisions flagged, denylisted with care,
purge queues hum and jobs tidy the lair.
Cap hit? I thump — polite, precise, and thin."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% 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 'feat(AP-24): artifact pinning and revision retention' clearly summarizes the main changes: adding artifact pinning functionality and revision retention policy as described throughout the PR.
Linked Issues check ✅ Passed The PR implements all core AP-24 requirements: artifact pinning with per-workspace cap, revision retention policy with denylist/purge, auto-deletion exemption for pinned artifacts, database schema updates, API endpoints, and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly aligned with AP-24 scope: pinning functionality, retention policy, database schema, API routes, jobs discovery, and supporting infrastructure. No billing-plan-specific limits or unrelated features introduced.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/pinning-revision-retention-3962

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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

Co-authored-by: Isaac Suttell <isaac@isaacsuttell.com>
@cursor cursor Bot temporarily deployed to pr-preview-104 May 27, 2026 05:30 Inactive
Co-authored-by: Isaac Suttell <isaac@isaacsuttell.com>
@cursor cursor Bot temporarily deployed to pr-preview-104 May 27, 2026 05:37 Inactive
coderabbitai[bot]
coderabbitai Bot previously requested changes May 27, 2026

@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: 8

Caution

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

⚠️ Outside diff range comments (1)
docs/ops/status/phase-backlog.md (1)

3-3: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the "Last updated" marker to reflect AP-24.

The "Last updated" line still references AP-22 (jobs lifecycle byte purge ownership), but this change marks AP-24 (pinning and revision retention) as complete on line 118. The marker should reflect the most recent update.

📝 Proposed fix
-Last updated: 2026-05-27 (AP-22 jobs lifecycle byte purge ownership).
+Last updated: 2026-05-27 (AP-24 pinning and revision retention).
🤖 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/ops/status/phase-backlog.md` at line 3, Update the "Last updated" marker
to reflect AP-24: replace the existing line "Last updated: 2026-05-27 (AP-22
jobs lifecycle byte purge ownership)" with "Last updated: 2026-05-27 (AP-24
pinning and revision retention)" so the header matches the change noted for
AP-24 (the completion referenced elsewhere in this file).
🤖 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/index.ts`:
- Around line 653-655: The call to runIdempotent wraps pinWebArtifact but
db.pinWebArtifact inside pinWebArtifact can throw
Error("pinned_artifact_cap_exceeded") which currently bubbles out as a 500;
update pinWebArtifact to catch errors from db.pinWebArtifact and map that
specific error to the proper contract error/response before returning (or
rethrowing) so runIdempotent never returns the raw DB error. Locate the
pinWebArtifact function (the call site used in runIdempotent) and add error
handling around the db.pinWebArtifact call to detect the
"pinned_artifact_cap_exceeded" message and convert it to the expected contract
error type/value.

In `@apps/jobs/README.md`:
- Line 25: Update the README table row for the "Retention" cron (schedule `0 * *
* *`) to fully describe the job: state that it marks non-current published
revisions as retained when `revision_retention_days` is set on a workspace,
writes `rd:` denylist keys for those retained revisions, and enqueues
revision-scoped byte purge tasks (the revision retention discovery job in the
stack context). Keep the cron schedule unchanged and use the exact terms
`revision_retention_days`, `rd:` denylist keys, and "revision-scoped byte purge"
so readers can map this description to the implementation.

In `@apps/jobs/src/discovery/retention.ts`:
- Around line 45-91: The current flow updates a revision to 'retained' inside
the runCommand handler (operation "lifecycle.revision.retain") before calling
applyRevisionPurgeSideEffects, which can fail and leave the row stranded because
discovery only looks for status='published'. Fix by either moving the DB update
so the status change to 'retained' happens only after
applyRevisionPurgeSideEffects succeeds, or by adding a recovery marker when the
handler succeeds but side-effects fail: e.g., have the handler record a durable
"retention_pending_purge" flag/column or an audit field (or re-emit a retryable
command) so a reconciler can re-run applyRevisionPurgeSideEffects; update code
references: modify the handler passed to runCommand, the SQL update of revisions
(currently setting status='retained'), and the call to
applyRevisionPurgeSideEffects to ensure side-effect failures either roll back
the status change or create a retryable recovery marker for later processing.
- Around line 17-20: The current preflight only checks env.BYTE_PURGE_QUEUE so
if env.DENYLIST is missing revisions can be marked retained while purge-side
effects are skipped; add a guard in the same preflight block to verify
env.DENYLIST (alongside BYTE_PURGE_QUEUE), call logOpError with the same or a
clear event key (e.g., "cron.queue_binding_missing" or "cron.denylist_missing")
when DENYLIST is absent, and return the same early result ({ discovered: 0,
enqueued: 0, cap_hit: false }) to avoid proceeding to retention logic (the code
path that marks revisions retained around the retention processing at/near Line
55).

In `@docs/ops/status/implementation.md`:
- Line 24: Update the `apps/jobs` retention description to explicitly state that
when `revision_retention_days` is set the retention cron not only marks
revisions as retained but also writes `rd:` denylist keys and enqueues a
revision-scoped byte purge job; mirror the wording/level of detail from the
changelog entry (lines mentioning the denylist `rd:` keys and enqueueing byte
purge) so the docs and changelog are consistent.

In `@packages/db/src/index.test.ts`:
- Around line 1015-1047: Add tests that enforce the 50-pin workspace cap by
exercising LocalRepository.pinWebArtifact until PINNED_ARTIFACT_CAP is reached
and asserting the repository returns or throws the expected
pinned_artifact_cap_exceeded error; use publishLocalArtifact to create
artifacts, resolveWebMember/verifyApiKey/getWebMemberByWorkOsUserId to obtain
actors, then call pinWebArtifact repeatedly and assert the final call fails with
the cap error and earlier calls succeed, and also add tests that check
idempotency of pinWebArtifact/unpinWebArtifact (calling with same idempotencyKey
returns the same result) and error cases (API key actor rejected,
cross-workspace artifact access, and missing artifact) to cover those branches.

In `@packages/db/src/queries/artifacts.ts`:
- Around line 138-146: The null-check in countPinned uses a raw SQL template
(sql`${artifacts.pinnedAt} is not null`); replace it with the Drizzle helper
isNotNull(artifacts.pinnedAt) for type safety and clarity and add the
corresponding import for isNotNull where other Drizzle helpers are imported so
the where clause becomes and(eq(artifacts.workspaceId, workspaceId),
eq(artifacts.status, "active"), isNotNull(artifacts.pinnedAt)).

In `@packages/db/src/repository/core.ts`:
- Around line 460-464: The check-then-write around
pinnedCount/PINNED_ARTIFACT_CAP is racy: replace the separate
entities.artifacts.countPinned(...) + setPinnedAt(artifact.id, ...) pattern with
an atomic DB-side operation (or a transaction with a proper lock) that enforces
the cap in one step; for example, perform a conditional update/insert that only
sets pinned_at when the number of pinned artifacts for member.workspace_id is <
PINNED_ARTIFACT_CAP, or execute setPinnedAt inside a serialized transaction that
locks the workspace’s artifact rows (or a workspace counter row) so only one
concurrent caller can increment/pin at the boundary, and throw
"pinned_artifact_cap_exceeded" when the DB conditional update affects 0 rows.
Ensure you modify the code paths using entities.artifacts.countPinned,
entities.artifacts.setPinnedAt, artifact.id and member.workspace_id accordingly.

---

Outside diff comments:
In `@docs/ops/status/phase-backlog.md`:
- Line 3: Update the "Last updated" marker to reflect AP-24: replace the
existing line "Last updated: 2026-05-27 (AP-22 jobs lifecycle byte purge
ownership)" with "Last updated: 2026-05-27 (AP-24 pinning and revision
retention)" so the header matches the change noted for AP-24 (the completion
referenced elsewhere in this file).
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5f5675ee-c463-4579-88b3-192e4a454b2a

📥 Commits

Reviewing files that changed from the base of the PR and between 1ad2436 and 879ce1d.

📒 Files selected for processing (44)
  • apps/api/src/index.ts
  • apps/jobs/README.md
  • apps/jobs/src/cron.ts
  • apps/jobs/src/discovery/auto-deletion.ts
  • apps/jobs/src/discovery/retention.ts
  • apps/jobs/src/lifecycle.test.ts
  • apps/jobs/src/lifecycle/revision-byte-purge-enqueue.ts
  • apps/jobs/src/lifecycle/revision-denylist.ts
  • apps/jobs/src/lifecycle/revision-prefix.ts
  • apps/jobs/src/lifecycle/revision-purge-side-effects.ts
  • docs/ops/project-status.md
  • docs/ops/status/changelog.md
  • docs/ops/status/coverage.md
  • docs/ops/status/implementation.md
  • docs/ops/status/phase-backlog.md
  • packages/config/src/index.ts
  • packages/contracts/openapi/api.json
  • packages/contracts/openapi/content.json
  • packages/contracts/openapi/upload.json
  • packages/contracts/src/common.ts
  • packages/contracts/src/mvp-contracts.test.ts
  • packages/contracts/src/openapi/api.ts
  • packages/contracts/src/routes.ts
  • packages/db/migrations/0013_pinning_and_revision_retention.sql
  • packages/db/snapshot/schema.sql
  • packages/db/src/access-links.test.ts
  • packages/db/src/agent-view.test.ts
  • packages/db/src/index.test.ts
  • packages/db/src/policy.ts
  • packages/db/src/queries/artifacts.ts
  • packages/db/src/queries/index.test.ts
  • packages/db/src/queries/revisions.ts
  • packages/db/src/queries/workspaces.ts
  • packages/db/src/repository/core.ts
  • packages/db/src/repository/interface.ts
  • packages/db/src/repository/local-access-links.test.ts
  • packages/db/src/repository/local-entities.ts
  • packages/db/src/repository/ports.ts
  • packages/db/src/repository/postgres-entities.test.ts
  • packages/db/src/repository/postgres-entities.ts
  • packages/db/src/repository/web-transforms.ts
  • packages/db/src/schema.ts
  • packages/db/src/types.ts
  • packages/worker-runtime/src/errors.ts

Comment thread apps/api/src/index.ts Outdated
Comment thread apps/jobs/README.md Outdated
Comment thread apps/jobs/src/discovery/retention.ts
Comment thread apps/jobs/src/discovery/retention.ts Outdated
Comment thread docs/ops/status/implementation.md Outdated
Comment on lines +1015 to +1047
it("pins and unpins artifacts for web members", async () => {
const repo = new LocalRepository({ apiKeyPepper: "pepper" });
const session = await repo.resolveWebMember({
workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ",
email: "user@example.com",
idempotencyKey: "workos-jti:pin",
now: "2026-01-01T00:00:00.000Z",
});
const keySecret = session.default_api_key?.secret;
const apiActor = keySecret ? await repo.verifyApiKey(keySecret) : null;
const webActor = await repo.getWebMemberByWorkOsUserId({ workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ" });
if (!apiActor || !webActor) {
throw new Error("expected actors");
}
const published = await publishLocalArtifact(repo, apiActor, "pin-me", "2026-01-01T00:00:01.000Z");

const pinned = await repo.pinWebArtifact({
actor: webActor,
idempotencyKey: "idem-pin",
artifactId: published.artifact_id,
now: new Date("2026-01-02T00:00:00.000Z"),
});
expect(pinned).toMatchObject({ id: published.artifact_id, pinned: true, auto_delete_at: null });

const unpinned = await repo.unpinWebArtifact({
actor: webActor,
idempotencyKey: "idem-unpin",
artifactId: published.artifact_id,
now: new Date("2026-01-03T00:00:00.000Z"),
});
expect(unpinned).toMatchObject({ id: published.artifact_id, pinned: false });
expect(unpinned.auto_delete_at).not.toBeNull();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add test coverage for the 50-artifact pinning cap.

The test validates the happy path for pin/unpin, but the PR objectives explicitly state that pinning is capped at 50 per workspace (PINNED_ARTIFACT_CAP). There is no test exercising this cap enforcement or verifying that pinned_artifact_cap_exceeded is raised when the limit is reached. Given that branch coverage is currently below the 80% threshold, adding cap enforcement coverage would help close that gap.

Additionally, consider adding tests for:

  • Idempotency of pinWebArtifact and unpinWebArtifact (mentioned in the review stack context as implemented)
  • Error cases: API key actor rejection, cross-workspace artifact access, missing artifact
🧪 Suggested test structure for cap enforcement
+  it("enforces the 50-artifact pinning cap per workspace", async () => {
+    const repo = new LocalRepository({ apiKeyPepper: "pepper" });
+    const session = await repo.resolveWebMember({
+      workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ",
+      email: "user@example.com",
+      idempotencyKey: "workos-jti:cap",
+      now: "2026-01-01T00:00:00.000Z",
+    });
+    const keySecret = session.default_api_key?.secret;
+    const apiActor = keySecret ? await repo.verifyApiKey(keySecret) : null;
+    const webActor = await repo.getWebMemberByWorkOsUserId({ workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ" });
+    if (!apiActor || !webActor) {
+      throw new Error("expected actors");
+    }
+
+    // Publish and pin 50 artifacts
+    for (let i = 0; i < 50; i++) {
+      const published = await publishLocalArtifact(repo, apiActor, `artifact-${i}`, `2026-01-01T00:00:${String(i).padStart(2, "0")}.000Z`);
+      await repo.pinWebArtifact({
+        actor: webActor,
+        idempotencyKey: `idem-pin-${i}`,
+        artifactId: published.artifact_id,
+        now: new Date(`2026-01-02T00:00:${String(i).padStart(2, "0")}.000Z`),
+      });
+    }
+
+    // 51st pin should fail with cap exceeded
+    const overflow = await publishLocalArtifact(repo, apiActor, "overflow", "2026-01-01T01:00:00.000Z");
+    await expect(
+      repo.pinWebArtifact({
+        actor: webActor,
+        idempotencyKey: "idem-pin-overflow",
+        artifactId: overflow.artifact_id,
+        now: new Date("2026-01-02T01:00:00.000Z"),
+      }),
+    ).rejects.toThrow("pinned_artifact_cap_exceeded");
+  });
📝 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("pins and unpins artifacts for web members", async () => {
const repo = new LocalRepository({ apiKeyPepper: "pepper" });
const session = await repo.resolveWebMember({
workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ",
email: "user@example.com",
idempotencyKey: "workos-jti:pin",
now: "2026-01-01T00:00:00.000Z",
});
const keySecret = session.default_api_key?.secret;
const apiActor = keySecret ? await repo.verifyApiKey(keySecret) : null;
const webActor = await repo.getWebMemberByWorkOsUserId({ workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ" });
if (!apiActor || !webActor) {
throw new Error("expected actors");
}
const published = await publishLocalArtifact(repo, apiActor, "pin-me", "2026-01-01T00:00:01.000Z");
const pinned = await repo.pinWebArtifact({
actor: webActor,
idempotencyKey: "idem-pin",
artifactId: published.artifact_id,
now: new Date("2026-01-02T00:00:00.000Z"),
});
expect(pinned).toMatchObject({ id: published.artifact_id, pinned: true, auto_delete_at: null });
const unpinned = await repo.unpinWebArtifact({
actor: webActor,
idempotencyKey: "idem-unpin",
artifactId: published.artifact_id,
now: new Date("2026-01-03T00:00:00.000Z"),
});
expect(unpinned).toMatchObject({ id: published.artifact_id, pinned: false });
expect(unpinned.auto_delete_at).not.toBeNull();
});
it("pins and unpins artifacts for web members", async () => {
const repo = new LocalRepository({ apiKeyPepper: "pepper" });
const session = await repo.resolveWebMember({
workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ",
email: "user@example.com",
idempotencyKey: "workos-jti:pin",
now: "2026-01-01T00:00:00.000Z",
});
const keySecret = session.default_api_key?.secret;
const apiActor = keySecret ? await repo.verifyApiKey(keySecret) : null;
const webActor = await repo.getWebMemberByWorkOsUserId({ workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ" });
if (!apiActor || !webActor) {
throw new Error("expected actors");
}
const published = await publishLocalArtifact(repo, apiActor, "pin-me", "2026-01-01T00:00:01.000Z");
const pinned = await repo.pinWebArtifact({
actor: webActor,
idempotencyKey: "idem-pin",
artifactId: published.artifact_id,
now: new Date("2026-01-02T00:00:00.000Z"),
});
expect(pinned).toMatchObject({ id: published.artifact_id, pinned: true, auto_delete_at: null });
const unpinned = await repo.unpinWebArtifact({
actor: webActor,
idempotencyKey: "idem-unpin",
artifactId: published.artifact_id,
now: new Date("2026-01-03T00:00:00.000Z"),
});
expect(unpinned).toMatchObject({ id: published.artifact_id, pinned: false });
expect(unpinned.auto_delete_at).not.toBeNull();
});
it("enforces the 50-artifact pinning cap per workspace", async () => {
const repo = new LocalRepository({ apiKeyPepper: "pepper" });
const session = await repo.resolveWebMember({
workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ",
email: "user@example.com",
idempotencyKey: "workos-jti:cap",
now: "2026-01-01T00:00:00.000Z",
});
const keySecret = session.default_api_key?.secret;
const apiActor = keySecret ? await repo.verifyApiKey(keySecret) : null;
const webActor = await repo.getWebMemberByWorkOsUserId({ workosUserId: "user_01J5K7Y8G9H0ABCDEFGHJKMNPQ" });
if (!apiActor || !webActor) {
throw new Error("expected actors");
}
// Publish and pin 50 artifacts
for (let i = 0; i < 50; i++) {
const published = await publishLocalArtifact(repo, apiActor, `artifact-${i}`, `2026-01-01T00:00:${String(i).padStart(2, "0")}.000Z`);
await repo.pinWebArtifact({
actor: webActor,
idempotencyKey: `idem-pin-${i}`,
artifactId: published.artifact_id,
now: new Date(`2026-01-02T00:00:${String(i).padStart(2, "0")}.000Z`),
});
}
// 51st pin should fail with cap exceeded
const overflow = await publishLocalArtifact(repo, apiActor, "overflow", "2026-01-01T01:00:00.000Z");
await expect(
repo.pinWebArtifact({
actor: webActor,
idempotencyKey: "idem-pin-overflow",
artifactId: overflow.artifact_id,
now: new Date("2026-01-02T01:00:00.000Z"),
}),
).rejects.toThrow("pinned_artifact_cap_exceeded");
});
🤖 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/index.test.ts` around lines 1015 - 1047, Add tests that
enforce the 50-pin workspace cap by exercising LocalRepository.pinWebArtifact
until PINNED_ARTIFACT_CAP is reached and asserting the repository returns or
throws the expected pinned_artifact_cap_exceeded error; use publishLocalArtifact
to create artifacts, resolveWebMember/verifyApiKey/getWebMemberByWorkOsUserId to
obtain actors, then call pinWebArtifact repeatedly and assert the final call
fails with the cap error and earlier calls succeed, and also add tests that
check idempotency of pinWebArtifact/unpinWebArtifact (calling with same
idempotencyKey returns the same result) and error cases (API key actor rejected,
cross-workspace artifact access, and missing artifact) to cover those branches.

Comment thread packages/db/src/queries/artifacts.ts
Comment thread packages/db/src/repository/core.ts Outdated
Exercise artifact countPinned/setPinnedAt query paths and retention/auto-deletion
cap_hit, no-op updates, and per-row error handling so CI stays above the 80% branch threshold.

Co-authored-by: Isaac Suttell <isaac@isaacsuttell.com>
@cursor cursor Bot temporarily deployed to pr-preview-104 May 27, 2026 05:44 Inactive
@cursor cursor Bot temporarily deployed to pr-preview-104 May 27, 2026 05:44 Inactive
Map pinned_artifact_cap_exceeded to 409 on web pin routes, preflight retention
bindings, run revision purge side effects before marking retained, enforce pin
cap atomically, and update jobs/docs/tests.

Co-authored-by: Isaac Suttell <isaac@isaacsuttell.com>
@cursor cursor Bot temporarily deployed to pr-preview-104 May 27, 2026 05:54 Inactive
@isuttell isuttell dismissed coderabbitai[bot]’s stale review May 27, 2026 06:03

Stale CodeRabbit changes requested on superseded head 879ce1d; latest head 155dacb has green Validate/PR Preview and CodeRabbit status SUCCESS with no actionable comments.

@isuttell isuttell enabled auto-merge (squash) May 27, 2026 06:04
@isuttell

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@isuttell isuttell disabled auto-merge May 27, 2026 06:54
@isuttell isuttell merged commit 664cb74 into main May 27, 2026
4 checks passed
@isuttell isuttell deleted the cursor/pinning-revision-retention-3962 branch May 27, 2026 06:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants