Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions agents-docs/arch-explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,16 @@ Journal cards:
3. `project.unified_thumbnail` (so journal cards still anchor visually to the project even when the entry has no media)
4. Otherwise `nil`

`unified_thumbnail` is a `has_one_attached` ActiveStorage attachment on Project populated by [`ComputeProjectUnifiedThumbnailJob`](../app/jobs/compute_project_unified_thumbnail_job.rb) — wraps `ShipChecks::UnifiedScreenshotFinder` (zine detection in repo, LLM-assisted) + `ShipChecks::UnifiedScreenshotProcessor` (PDF/raster → JPEG bytes). Cache key columns: `unified_thumbnail_source_url` (raw URL the rasterization is from), `unified_thumbnail_etag` (HTTP ETag for conditional GET), and `unified_thumbnail_checked_at` (last attempt timestamp). Both controllers preload via `.preload(unified_thumbnail_attachment: :blob)` on the project scope and `{ project: { unified_thumbnail_attachment: :blob } }` on journal preloads — the bare `with_attached_*` macro also drags in `variant_records` and `preview_image_attachment` (Bullet's "AVOID eager loading" warning), which we don't use.
`unified_thumbnail` is a `has_one_attached` ActiveStorage attachment on Project populated by [`ComputeProjectUnifiedThumbnailJob`](../app/jobs/compute_project_unified_thumbnail_job.rb) — wraps `ShipChecks::UnifiedScreenshotFinder.find_url` (zine detection in repo, LLM-assisted; `skip_nil: true` so a "no zine" result isn't cached) + `ShipChecks::UnifiedScreenshotProcessor` (PDF/raster → JPEG bytes). The job accepts `perform(project_id, source_url:, force:, allow_representative:)`: a caller-supplied `source_url` (preflight) is used directly and skips the finder; otherwise `force`/`allow_representative` are forwarded to `find_url`. Cache key columns: `unified_thumbnail_source_url` (raw URL the rasterization is from), `unified_thumbnail_etag` (HTTP ETag for conditional GET), and `unified_thumbnail_checked_at` (last attempt timestamp). Both controllers preload via `.preload(unified_thumbnail_attachment: :blob)` on the project scope and `{ project: { unified_thumbnail_attachment: :blob } }` on journal preloads — the bare `with_attached_*` macro also drags in `variant_records` and `preview_image_attachment` (Bullet's "AVOID eager loading" warning), which we don't use.

#### Refresh contract

The job is enqueued from three places:
- `Project` after_commit when (a) a new project with a `repo_link` is created, (b) `repo_link` changes in either direction (set, edited, or cleared), or (c) a project is undiscarded and still has a `repo_link`.
- `AttachShipUnifiedScreenshotJob` after a ship's `frozen_screenshot` is populated.
- [`RefreshStaleUnifiedThumbnailsJob`](../app/jobs/refresh_stale_unified_thumbnails_job.rb) — recurring hourly, picks up projects with `unified_thumbnail_checked_at IS NULL OR < 24h ago`, jittered across a 30-minute window, capped at `PER_RUN_LIMIT = 200` per run.
Zines are added near *ship* time, not at project creation, so discovery is triggered only when a zine plausibly exists. The job is enqueued from:
- `ProjectsController#refresh_cover` — the owner-triggered "Check for my zine" button on the project page (verified owners only via `ProjectPolicy#refresh_cover?`; `force: true`, `allow_representative: false`; Rack::Attack throttled `refresh_cover/user`). The frontend polls `#cover_status`, which reports `working`/`found`/`none` by comparing `unified_thumbnail_checked_at` to the POST time.
- `ShipPreflightJob` — after preflight's checks finish, if `HasZinePage` passed it reuses the already-built `SharedContext` to find the zine URL (no second GitHub fetch) and enqueues with that `source_url` (`allow_representative: false`).
- `AttachShipUnifiedScreenshotJob` after a ship's `frozen_screenshot` is populated (keeps the representative-image fallback).
- `Project` after_commit when `repo_link` changes (cleared or swapped) — to purge the now-stale cover. It deliberately does NOT scan on repo add/change (a freshly linked repo has no zine yet).
- [`RefreshStaleUnifiedThumbnailsJob`](../app/jobs/refresh_stale_unified_thumbnails_job.rb) — recurring hourly, but **only projects that already have a `unified_thumbnail` attached** (a cheap etag refresh to catch zine *updates*), with `unified_thumbnail_checked_at IS NULL OR < 24h ago`, jittered across a 30-minute window, capped at `PER_RUN_LIMIT = 200` per run.

Inside the job, the freshness model is **never purge without positive proof**:
- `repo_link` blank → purge directly.
Expand Down
2 changes: 1 addition & 1 deletion agents-docs/arch-projects-journals.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Ship (audit-trailed)
- **URL validations** on `demo_link`, `repo_link`
- **`is_unlisted`**: controls public visibility (default false)
- **PaperTrail** for audit history
- **`unified_thumbnail` (ActiveStorage attachment)** + `unified_thumbnail_source_url` / `unified_thumbnail_etag` / `unified_thumbnail_checked_at` columns: cached, pre-rasterized zine/poster image used as the project's cover on the bulletin board explore feed and `/api/v1/explore`. Populated by [`ComputeProjectUnifiedThumbnailJob`](../app/jobs/compute_project_unified_thumbnail_job.rb) (uses `ShipChecks::UnifiedScreenshotFinder` + `ShipChecks::UnifiedScreenshotProcessor.download_with_etag` for conditional GET freshness), enqueued via Project after_commit on create / `repo_link` change / undiscard, from `AttachShipUnifiedScreenshotJob` after a ship's zine is discovered, and by the hourly [`RefreshStaleUnifiedThumbnailsJob`](../app/jobs/refresh_stale_unified_thumbnails_job.rb). See [arch-explore.md](arch-explore.md) for the cover-image priority chain and the full refresh contract.
- **`unified_thumbnail` (ActiveStorage attachment)** + `unified_thumbnail_source_url` / `unified_thumbnail_etag` / `unified_thumbnail_checked_at` columns: cached, pre-rasterized zine/poster image used as the project's cover on the bulletin board explore feed and `/api/v1/explore`. Populated by [`ComputeProjectUnifiedThumbnailJob`](../app/jobs/compute_project_unified_thumbnail_job.rb) (uses `ShipChecks::UnifiedScreenshotFinder` + `ShipChecks::UnifiedScreenshotProcessor.download_with_etag` for conditional GET freshness). Because zines are added near ship time (not at creation), it's enqueued only when a zine plausibly exists: on demand from the project page's "Check for my zine" button (`ProjectsController#refresh_cover`, verified owners), from `ShipPreflightJob` when the zine check passes (reusing preflight's repo fetch), from `AttachShipUnifiedScreenshotJob` after a ship's zine is discovered, on Project after_commit when `repo_link` changes (cleared or swapped) to purge the stale cover (no scan), and by the hourly [`RefreshStaleUnifiedThumbnailsJob`](../app/jobs/refresh_stale_unified_thumbnails_job.rb) (limited to projects that already have a cover). See [arch-explore.md](arch-explore.md) for the cover-image priority chain and the full refresh contract.

**Key methods:**
- `time_logged` — aggregates duration from LapseTimelapse, LookoutTimelapse, and YouTubeVideo (stretch-multiplied) recordings across all kept journal entries on the project, plus admin-set `manual_seconds`.
Expand Down
4 changes: 2 additions & 2 deletions agents-docs/arch-services-infra.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,12 @@ Outbound sync for Users, Projects, ShopOrders, Ships, and the four review types

3. `AttachShipUnifiedScreenshotJob` — slow path. Finds a source URL via `ShipChecks::UnifiedScreenshotFinder` (four-stage strategy below), caches it on `ship.frozen_screenshot`, processes via `ShipChecks::UnifiedScreenshotProcessor` (libvips → JPEG, progressive quality reduction until ≤5MB; supports PNG/JPG/WEBP/GIF + PDF rendered through libpoppler-glib8), then POSTs the bytes to `https://content.airtable.com/v0/{base}/{recordId}/Screenshot/uploadAttachment` via `AirtableSync.upload_attachment!`. The job retries with `wait: 15.seconds, attempts: 8` if the parallel upload job hasn't yet created the Airtable record (no airtable_id in `AirtableSync`). After a successful attachment, writes a sentinel `AirtableSync` row keyed `"Ship#<id>/unified/screenshot"` so retries skip — `uploadAttachment` *appends* to the field array, so a repeat would duplicate the screenshot. SVG sources are still skipped (would require librsvg).

`UnifiedScreenshotFinder` strategy, in priority order:
`UnifiedScreenshotFinder.find_url(project, ctx: nil, allow_representative: true, force: false)` strategy, in priority order. Callers can pass an already-built `SharedContext` via `ctx:` to avoid re-fetching the repo tree / re-running vision descriptions (preflight does this); `allow_representative: false` restricts to real zines (skips stage 4); `force: true` busts the cache. Results are cached 6h keyed by `[project.id, updated_at, allow_representative]` with `skip_nil: true`, so a "no zine" outcome is **not** cached and a later-added zine is found on the next check.

1. **Filename regex over the repo tree** — `zine|poster|flyer|magazine|page` + image/PDF extension. Fast, no LLM.
2. **LLM filter over the repo tree** — list every image/PDF file in the tree (regardless of name) and ask the LLM which is the zine. Catches zines named "submission.pdf", "{project}.png", etc. Cheap text-only call over filenames.
3. **LLM search of README images** — reuses the descriptions already memoized for `HasZinePage`, asks the LLM if any image is a zine.
4. **Fallback when no zine exists** — LLM picks the best representative project image from the README (entire assembly, finished build on a desk, etc.) so the YSWS row still gets a usable screenshot.
4. **Fallback when no zine exists (only when `allow_representative: true`)** — LLM picks the best representative project image from the README (entire assembly, finished build on a desk, etc.) so the YSWS row still gets a usable screenshot. The on-demand cover button and preflight piggyback pass `allow_representative: false`, so this fallback is ship-approval-only.

No HCB API code is touched — Fallout only pushes data into the YSWS table; downstream YSWS automation handles any actual money flow.

Expand Down
40 changes: 38 additions & 2 deletions app/controllers/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class ProjectsController < ApplicationController
allow_unauthenticated_access only: %i[show] # Listed project details are public from Explore and the public API.
allow_trial_access only: %i[index show new create edit update destroy onboarding export_journal] # Trial users can manage their single project and export their journal
skip_onboarding_redirect only: %i[show] # Public project details must stay viewable before account onboarding.
before_action :set_project, only: %i[show edit update destroy export_journal]
before_action :set_project, only: %i[show edit update destroy export_journal refresh_cover cover_status]
before_action :set_project_unfurl_meta, only: :show

def onboarding
Expand Down Expand Up @@ -87,6 +87,7 @@ def show
share: project_policy.share?, # Gates the "Copy share link" overflow menu item — true only for listed, non-discarded projects
ship: project_policy.ship?,
manage_collaborators: collab_enabled && project_policy.manage_collaborators?,
refresh_cover: project_policy.refresh_cover?, # Gates the owner-only "Check for my zine" cover action
# JournalEntriesController only allows trial access on :preview — exclude trial users so they fall through to the locked button below.
create_journal_entry: !current_user&.trial? && JournalEntryPolicy.new(current_user, @project.journal_entries.build(user: current_user)).create?,
# Trial owner or trial collaborator who would gain create access on verifying — drives the "locked" feather button with a verify prompt.
Expand Down Expand Up @@ -210,11 +211,46 @@ def export_journal
disposition: "attachment"
end

def refresh_cover
authorize @project, :refresh_cover?
since = Time.current
# force: bust the 6h finder cache so a freshly added zine is picked up now.
# allow_representative: false — the button only sets a cover when a real zine is found.
ComputeProjectUnifiedThumbnailJob.perform_later(@project.id, force: true, allow_representative: false)
# iso8601(6): keep microsecond precision so cover_status can't read a concurrent job's same-second
# checked_at write as "already finished" and resolve before this forced scan runs.
render json: { since: since.iso8601(6) }
end

def cover_status
authorize @project, :refresh_cover?
checked_at = @project.unified_thumbnail_checked_at
since = begin
Time.iso8601(params[:since].to_s)
rescue ArgumentError, TypeError
nil
end
# The job always advances unified_thumbnail_checked_at, so checked_at moving past the POST
# time means the scan finished — attached? then tells us whether a zine was found.
state =
if since && (checked_at.nil? || checked_at <= since)
"working"
elsif @project.unified_thumbnail.attached?
"found"
else
"none"
end
render json: {
state: state,
unified_thumbnail_url: state == "found" ? url_for(@project.unified_thumbnail) : nil
}
end

private

def set_project
scope = Project.kept
scope = scope.includes(:user, unified_thumbnail_attachment: :blob) if action_name == "show"
scope = scope.includes(:user, unified_thumbnail_attachment: :blob) if %w[show cover_status].include?(action_name)
@project = scope.find(params[:id])
end

Expand Down
Loading
Loading