diff --git a/README.md b/README.md index a5662070..42dfc961 100644 --- a/README.md +++ b/README.md @@ -429,7 +429,7 @@ Keep several, and delete old ones yourself. A restore needs a **new, empty volume** and runs **before the instance's first start**, as a one-off command in a container of the image you will run. It refuses a volume that already holds a Collector database (or the `-wal`, `-shm` or `-journal` file of one, or the queue, cache or cable database) and changes nothing: to restore over a running instance, restore onto a new volume and switch to it. It restores a backup taken by the same release or an older one, which the first start then migrates as any upgrade does; a backup from a newer release is refused. Use the image tag you run (`COLLECTOR_IMAGE`), and a release that has `backup:restore`. -A restore ends every session (everyone signs in again) and drops pending bulk-removal Undos and selections; no collection is lost. It leaves the art index file behind: with art matching on, press **Build art index** on the admin catalog page afterwards, which needs no downloads. +A restore ends every session (everyone signs in again) and drops pending bulk-removal Undos and selections; no collection is lost. It leaves the art index file behind: with art matching on, press **Build art index** on the admin catalog page afterwards, which needs no downloads. Until you do, that page reads "Index file missing" and `catalog:status` "Art matching: index file missing". **Docker Compose.** Create the volume without starting Collector, and find its real name (Compose prefixes the project's name; a different project name is a different, empty volume): @@ -478,7 +478,7 @@ bin/kamal setup --skip-push --version 0.1.0 Give the exact release as the tag: releases deploy by version, so no `latest` tag is on the server. This path is documented and its container command is tested in CI, but it hasn't been run through Kamal itself. -**After a restore**, before you hand the instance back: compare the counts the command printed (accounts, users, lots) with what you expect; start it; sign in; with art matching on press **Build art index**; check your settings (`COLLECTOR_*`) and your secret. +**After a restore**, before you hand the instance back: compare the counts the command printed (accounts, users, lots) with what you expect; start it; sign in; with art matching on press **Build art index** (until then the admin catalog page reads "Index file missing" and `catalog:status` "Art matching: index file missing"); check your settings (`COLLECTOR_*`) and your secret. **A damaged, cut-short or wrong file is refused** with a plain message and the volume is left as it was: a file still being copied, a file from another tool, a collector's export (use **Import collection** for that), a backup of a newer release, a snapshot that fails SQLite's integrity or foreign-key check. diff --git a/app/models/mtg/art.rb b/app/models/mtg/art.rb index 3ce92e12..8ab3bd55 100644 --- a/app/models/mtg/art.rb +++ b/app/models/mtg/art.rb @@ -34,7 +34,8 @@ def self.after_refresh(run) # The art line `catalog:status[mtg]` prints (AC-3.11), from the latest build run that wasn't skipped. A build still # marked running reads as the admin catalog page words it (MTG::Art::Operation): building with no heartbeat while a # worker holds the job of a build that stopped beating (MTG::ArtBuild#stalled?), interrupted once nothing is working - # on it (MTG::ArtBuild#left_behind?), both judged by the queue as it was read before the build. + # on it (MTG::ArtBuild#left_behind?), both judged by the queue as it was read before the build. A finished build whose + # index file isn't on the volume reads as missing, and says to press the admin page's button (spec 039 decision 3). def self.status_line return "Art matching: off (set #{ENV_NAME}=true to turn it on)" unless enabled? @@ -52,6 +53,10 @@ def self.status_line elsif run.running? "Art matching: building since #{run.started_at.utc.iso8601}; #{progress}, #{run.failed_count} failed " \ "(last heartbeat #{run.heartbeat_at.utc.iso8601})" + elsif run.finished? && !run.index_file_present? + "Art matching: index file missing (#{run.index_file.presence || "no file name recorded"}); press Build art index on the " \ + "admin catalog page to write it again. #{run.indexed_count} artworks indexed, #{run.without_image_count} without an image, " \ + "#{run.failed_count} failed images" elsif run.finished? "Art matching: ready; #{run.indexed_count} artworks indexed, #{run.without_image_count} without an image, " \ "#{run.failed_count} failed images; index #{run.index_file}" diff --git a/app/models/mtg/art/index.rb b/app/models/mtg/art/index.rb index 76b27399..e6c88543 100644 --- a/app/models/mtg/art/index.rb +++ b/app/models/mtg/art/index.rb @@ -25,6 +25,11 @@ def self.current = files.first def self.built_for?(catalog_version) = files.any? { it.basename.to_s.start_with?("art-index-#{catalog_version}-#{MTG::Art::Settings.digest}-") } + # True when a file of exactly this name is in the index directory (spec 039 decision 4). The name must be one this + # class writes, so nothing outside the directory is looked at; the file isn't opened. A blank name, a name of another + # shape and a directory that can't be examined are all "not there". + def self.file?(name) = NAME.match?(name.to_s) && dir.join(name.to_s).file? + # Only the current or previous index, by its exact name; anything else is nil (AC-4.2). def self.path_for(name) = NAME.match?(name.to_s) ? files.first(KEEP).find { it.basename.to_s == name } : nil diff --git a/app/models/mtg/art/operation.rb b/app/models/mtg/art/operation.rb index b98bef95..ec53c7c1 100644 --- a/app/models/mtg/art/operation.rb +++ b/app/models/mtg/art/operation.rb @@ -3,9 +3,10 @@ # both by MTG::ArtBuild#stalled? and #left_behind?). The two differ only while the queue holds a build job that no # running build is being worked on by: one that waits, or one a worker has taken that hasn't opened its build yet. The # page then says "Queued." or "Queued to retry.", and the status line still describes the latest build (interrupted, -# ready or failed), or says that no build has run yet. +# ready, index file missing or failed), or says that no build has run yet. class MTG::Art::Operation < Catalog::Operation OFF = "Art matching is off. Set #{MTG::Art::ENV_NAME}=true to turn it on.".freeze + INDEX_MISSING = "Index file missing. Build the art index again.".freeze NEEDS_CATALOG = "Refresh the catalog first: the art index is built from its cards.".freeze # A restart that stops the worker in good order releases the job it holds: the queue runs it again, and it closes its # own build and carries on. After a crash or a kill the job stays held until the queue notices its worker is gone @@ -36,17 +37,19 @@ def build @build = MTG::ArtBuild.latest end - # :off, :queued, :building, :stalled, :interrupted, :never, :ready or :failed. A build still marked running that - # nothing is working on any more is interrupted: its heartbeat stopped 10 minutes ago, or 2 minutes ago and the queue - # has failed its job or no longer holds it (issue #73). One whose heartbeat stopped 10 minutes ago while a worker - # still holds its job is stalled instead (issue #79). + # :off, :queued, :building, :stalled, :interrupted, :never, :ready, :index_missing or :failed. A build still marked + # running that nothing is working on any more is interrupted: its heartbeat stopped 10 minutes ago, or 2 minutes ago + # and the queue has failed its job or no longer holds it (issue #73). One whose heartbeat stopped 10 minutes ago while + # a worker still holds its job is stalled instead (issue #79). A finished build whose index file isn't on the volume + # is :index_missing (spec 039); it is judged only once nothing is in flight or running, so the states above it win. def state @state ||= if !MTG::Art.enabled? then :off elsif build&.running? then running_state elsif queue.any? then :queued elsif build.nil? then :never - else build.finished? ? :ready : :failed + elsif build.finished? then build.index_file_present? ? :ready : :index_missing + else :failed end end @@ -64,6 +67,7 @@ def summary when :interrupted then "Interrupted. Build it again to carry on from where it stopped." when :never then catalog_loaded? ? "No build has run yet. One starts after the next catalog refresh, or you can start it here." : NEEDS_CATALOG when :ready then "Ready." + when :index_missing then INDEX_MISSING when :failed then failed_summary end end @@ -84,9 +88,7 @@ def facts when :building, :stalled, :interrupted [ fact("Started", build.started_at), fact("Images fetched", build.fetched_count), fact("Failed images", build.failed_count), fact("Last heartbeat", build.heartbeat_at, relative: true) ] - when :ready - [ fact("Finished", build.finished_at), fact("Artworks indexed", build.indexed_count), - fact("Without an image", build.without_image_count), fact("Failed images", build.failed_count) ] + when :ready, :index_missing then finished_facts when :failed then failed_facts else [] end @@ -114,6 +116,14 @@ def retrying? # Why the build failed in plain words, with the error it stored as the detail (issue #84). def failure = Catalog::FailureReason.for(build.message, collectible_type) + # What a finished build records, plus the file's name when that file is the point (spec 039 decision 2). + def finished_facts + [ fact("Finished", build.finished_at), + (fact("Index file", build.index_file.presence || "none recorded") if state == :index_missing), + fact("Artworks indexed", build.indexed_count), fact("Without an image", build.without_image_count), + fact("Failed images", build.failed_count) ].compact + end + def failed_summary = "Failed. #{failure.sentence}" def failed_facts diff --git a/app/models/mtg/art_build.rb b/app/models/mtg/art_build.rb index 891fb501..73be610c 100644 --- a/app/models/mtg/art_build.rb +++ b/app/models/mtg/art_build.rb @@ -71,6 +71,10 @@ def stalled_minutes = ((Time.current - heartbeat_at) / 60).floor # the first two ask #stalled? before it. def left_behind?(queue) = stale? || (quiet? && job_ended?(queue)) + # Whether the index file this build wrote is still on the volume (spec 039). The row and the file can disagree after + # a restore (a backup holds the database only) or when the directory was lost. + def index_file_present? = MTG::Art::Index.file?(index_file) + def counts = COUNTS.index_with { |name| public_send(:"#{name}_count") } private diff --git a/docs/architecture/catalog-operations.md b/docs/architecture/catalog-operations.md index 4f446a3d..4f04d8fa 100644 --- a/docs/architecture/catalog-operations.md +++ b/docs/architecture/catalog-operations.md @@ -33,6 +33,7 @@ How the catalog changes, and how admins start, watch and recover that work: the - **Left behind / interrupted:** a run still marked running that nothing works on any more; see States and transitions for the rules. On the pages and in `catalog:status` it reads "interrupted", and a starting job closes it as failed with the message `interrupted` (spec 015 Glossary, AC-3.2, AC-3.3, AC-3.9, AC-4.10; #60, #73). - **Waiting retry:** an unfinished job of the same operation (for a refresh, queued for the same type), other than the starting one, that the queue holds for a later time (spec 024 Problem Statement, decisions 3 and 5; `Catalog::Operation::Queue#waiting_to_retry?`). - **Starting job:** the job whose `Catalog::Refresh#call` or `MTG::Art::Build#call` is running the start guard (spec 024 Problem Statement). +- **Ready (art index):** the latest build not skipped is finished and the index file it names (`MTG::ArtBuild#index_file`) is in `storage/catalog/mtg/art/index/`. The file is judged by its name only: `MTG::Art::Index.file?` accepts only a name of the index's own shape (`MTG::Art::Index::NAME`), makes one `stat` per panel render or status line, and never opens or caches the file. A blank or other-shaped name, an absent directory and a volume that can't be examined all read as missing (spec 039 decisions 1 and 4; #122). - **Stages of a refresh:** download, sync, retire, index, in that order, shown as "Download", "Sync cards", "Retire missing cards", "Rebuild name index" (`Catalog::RefreshRun::STAGES`, `Catalog::RefreshOperation::STAGES`; spec 015 Glossary). - **Job states on the jobs pages:** Failed (failed, not retried or discarded), Running (claimed by a worker), Queued (ready, or blocked by a concurrency limit, marked "waiting"), Scheduled (due later, including retry waits); every job in every queue counts (spec 015 Glossary, AC-6.1, AC-6.2). - **Admin / member:** a signed-in user with or without `admin` (spec 015 Glossary). @@ -110,10 +111,11 @@ What the art index section shows (`MTG::Art::Operation`; spec 015 Story 4): | Building | "Building.", artworks fingerprinted of the total as a bar, images fetched, failed images, start time and last heartbeat; button disabled; the page polls (spec 015 AC-4.2, AC-4.10) | | Heartbeat older than `STALE_AFTER`, its job still claimed | "Building, no heartbeat for N minutes." with the advice "If it stays that way, restart the app. The build then carries on from where it stopped, or reads interrupted and can be built again.", the same bar and facts; button disabled whatever else waits; the page polls; no link to the Failed list (spec 015 AC-4.11; #79) | | Left behind | "Interrupted. Build it again to carry on from where it stopped.", the bar and facts, a link to the Failed list when its job is there; button enabled unless another build job is in flight, then "Queued." (spec 015 AC-4.10; #73) | -| Finished | "Ready.", finish time, artworks indexed, without an image, failed images (spec 015 AC-4.7) | +| Finished, its index file on the volume | "Ready.", finish time, artworks indexed, without an image, failed images (spec 015 AC-4.7; spec 039 AC-1.4) | +| Finished, its index file not on the volume | "Index file missing. Build the art index again.", finish time, the file's name ("none recorded" for a row without one), artworks indexed, without an image, failed images; button enabled unless a job is in flight; no meter, no link to the Failed list; the page doesn't poll. Judged only once nothing is queued or running, so those states win (spec 039 decisions 2 and 6; #122) | | Failed | "Failed. <plain sentence>", finish time, the index still in use (or "none"), "Technical detail", a link to the Failed list when a build job is there (spec 015 AC-4.8; #84) | -A start request while a build is in flight queues nothing and answers "An art index build is already queued or running."; one while it is unavailable answers with the reason (spec 015 AC-4.4, FR-3). `MTG::Art.status_line`, the art line of `catalog:status`, words a build the same way (off, no build yet, building, building with no heartbeat and "Restart the app if it stays that way; the build then carries on, or reads interrupted.", interrupted with the advice to run the refresh task, ready, failed with the stored message), except that it always describes the latest build, never "Queued." (spec 011 AC-3.11; spec 015 AC-4.1, AC-4.11). +A start request while a build is in flight queues nothing and answers "An art index build is already queued or running."; one while it is unavailable answers with the reason (spec 015 AC-4.4, FR-3). `MTG::Art.status_line`, the art line of `catalog:status`, words a build the same way (off, no build yet, building, building with no heartbeat and "Restart the app if it stays that way; the build then carries on, or reads interrupted.", interrupted with the advice to run the refresh task, ready, failed with the stored message), except that it always describes the latest build, never "Queued." (spec 011 AC-3.11; spec 015 AC-4.1, AC-4.11). A finished build whose file is missing reads `Art matching: index file missing (); press Build art index on the admin catalog page to write it again.` with its counts, "(no file name recorded)" for a row without a name, and never "ready"; since the line describes the latest build, it still says to press the button while a build job is queued (spec 039 decision 3; #122). Pressing it is the recovery: the build fingerprints only what lacks a fingerprint, so after a restore it fetches no image and writes the file again (spec 039 decision 5, AC-3.2; spec 030 AC-2.8). ### What the source does for a refresh (`MTG::Scryfall::Source`) @@ -188,6 +190,7 @@ Filter chips Failed (the default), Running, Queued and Scheduled, each with its - A host suspended long enough for the queue to fail a working job can show its run as interrupted until the worker next records progress (normally within 2 seconds, up to about 70 in a hung request); a refresh started in that window closes it and runs beside it. A working build can do the same while one image takes more than 90 seconds (spec 015 Error Scenarios). - When the database is too busy for a build to record its failure, or to open its run, the section reads "Building." or "Queued." with "Retrying at" instead of "Queued to retry" (spec 015 Error Scenarios). - A skipped manual refresh behind a scheduled job's retry may end as "already applied"; and if the waiting retry later fails for good, the skipped work waits for the next weekly refresh or an admin (spec 024 decision 1). +- A finished build is judged by its file's name only: a damaged file, or one built for older settings or an older catalog version, still reads "Ready." (spec 039 Non-Goals, maintainer ruling 2026-11-03). - [#77](https://github.com/plainprogrammer/Collector/issues/77): an admin can't start a refresh or build, or cancel the wait, while a rate-limited job waits to retry. - [#86](https://github.com/plainprogrammer/Collector/issues/86): rate-limit retries count over a job's whole life (`MAX_RETRIES`), so a long build that is rate limited four times fails, though it did work between them. - [#111](https://github.com/plainprogrammer/Collector/issues/111): the `poll` controller's pauses (hidden tab, busy Turbo, open menu) have no spec source; whether they are a rule waits for the maintainer's ruling. @@ -200,11 +203,13 @@ Filter chips Failed (the default), Running, Queued and Scheduled, each with its - [Spec 015](../specs/015-admin-catalog-and-jobs/spec.md): all. - [Spec 024](../specs/024-refresh-rate-limit-wait/spec.md): all. - [Spec 023](../specs/023-signed-out-catalog-browsing/spec.md) decision 7, for the visitor's not-loaded notice only. +- [Spec 039](../specs/039-art-index-missing-file/spec.md): all. - ADRs [0014](../adr/0014-hand-built-admin-jobs-console.md), [0015](../adr/0015-admin-progress-by-polling.md), [0018](../adr/0018-collectible-agnostic-catalog-core.md). -- Issues [#60](https://github.com/plainprogrammer/Collector/issues/60), [#61](https://github.com/plainprogrammer/Collector/issues/61), [#73](https://github.com/plainprogrammer/Collector/issues/73), [#74](https://github.com/plainprogrammer/Collector/issues/74), [#78](https://github.com/plainprogrammer/Collector/issues/78), [#79](https://github.com/plainprogrammer/Collector/issues/79), [#80](https://github.com/plainprogrammer/Collector/issues/80), [#81](https://github.com/plainprogrammer/Collector/issues/81), [#84](https://github.com/plainprogrammer/Collector/issues/84); open: [#41](https://github.com/plainprogrammer/Collector/issues/41), [#77](https://github.com/plainprogrammer/Collector/issues/77), [#86](https://github.com/plainprogrammer/Collector/issues/86), [#111](https://github.com/plainprogrammer/Collector/issues/111). +- Issues [#60](https://github.com/plainprogrammer/Collector/issues/60), [#61](https://github.com/plainprogrammer/Collector/issues/61), [#73](https://github.com/plainprogrammer/Collector/issues/73), [#74](https://github.com/plainprogrammer/Collector/issues/74), [#78](https://github.com/plainprogrammer/Collector/issues/78), [#79](https://github.com/plainprogrammer/Collector/issues/79), [#80](https://github.com/plainprogrammer/Collector/issues/80), [#81](https://github.com/plainprogrammer/Collector/issues/81), [#84](https://github.com/plainprogrammer/Collector/issues/84), [#122](https://github.com/plainprogrammer/Collector/issues/122); open: [#41](https://github.com/plainprogrammer/Collector/issues/41), [#77](https://github.com/plainprogrammer/Collector/issues/77), [#86](https://github.com/plainprogrammer/Collector/issues/86), [#111](https://github.com/plainprogrammer/Collector/issues/111). ## Distillation log | Date | Distilled | Pull request | |---|---|---| | 2026-10-15 | Specs 002 (FR-3–FR-6 and their Error Scenarios rows), 011 (FR-2's build run), 015 and 024; issues #60, #61, #73, #74, #78–#81 and #84, and the open #41, #77 and #86; and CLAUDE.md's "Admin catalog and jobs" bullet | [#110](https://github.com/plainprogrammer/Collector/pull/110) | +| 2026-11-06 | Spec 039 (the art index's "Index file missing" state, the status line's missing wording, "ready" judged by the build's own named file, the accepted limit on judging by name only) and issue #122 | [#175](https://github.com/plainprogrammer/Collector/pull/175) | diff --git a/docs/architecture/release-and-deploy.md b/docs/architecture/release-and-deploy.md index ede0cde7..d40a7bf7 100644 --- a/docs/architecture/release-and-deploy.md +++ b/docs/architecture/release-and-deploy.md @@ -21,7 +21,7 @@ How Collector is built into an image, published, deployed and kept safe: the con - **Development environment** (stack, `bin/setup`, `bin/ci`, the Claude Code hooks, worktrees, dev ports, libvips in development) has no area doc; its facts stay in CLAUDE.md, where an agent needs them first (specs 001 stack, CI and hooks parts, 003, 014; spec 027 OD3 and D2). Those specs freeze with this area (see Sources). That includes which branches the pre-push hook runs `bin/ci checks` for (spec 035 decision 3, Story 3; `.claude/hooks/pre_push_ci.rb`, `lib/collector/push_scope.rb`); that it runs a `bin/ci` group at all is here. - **Data portability** ([data-portability.md](data-portability.md); specs 017, 022, 025, 026, 028, 029, 030): the export format, what a preview and an apply do, the file's content rules, and which of the export and the backup to use for what (spec 030 decision 11). The upload's size cap (`CollectionImport::MAX_BYTES`) and what an apply writes are there; the layers that refuse a body over 40 MB and the stored file's retention are here. -- **Catalog operations** ([catalog-operations.md](catalog-operations.md)): the weekly refresh (`config/recurring.yml`), the art index build that a restore asks the operator to press, and the wording of a finished build whose index file is missing ([#122](https://github.com/plainprogrammer/Collector/issues/122)). +- **Catalog operations** ([catalog-operations.md](catalog-operations.md)): the weekly refresh (`config/recurring.yml`), the art index build that a restore asks the operator to press, and the wording of a finished build whose index file is missing, which is what the admin page and `catalog:status` read after a restore until that build is pressed (spec 039; [#122](https://github.com/plainprogrammer/Collector/issues/122)). - **Tenancy and accounts** (no area doc yet; spec 004): the ownership rule behind `ActiveStorageGate` is here only as ADR 0022 states it. - **Scanner** (no area doc yet): the OCR engine is fetched and checksum-verified during the image build (`bin/fetch-ocr-engine`, spec 007, [ADR 0001](../adr/0001-browser-ocr-engine-and-asset-hosting.md)); how it is served is the scanner's. @@ -239,7 +239,7 @@ CI proves one release step of it: the previous release's published image, seeded - **Open (code and spec disagree):** spec 012 AC-3.3 reads "Given a pre-release tag such as `v1.0.0-rc.1` … Then the registry has `1.0.0-rc.1` and `latest`", which contradicts the same spec's Goals and FR-2 ("Move `latest` on a pre-release" is a must-not), ADR 0009's table, `docs/releasing.md` and the workflow, which publish the pre-release's own tag only: `type=semver,pattern={{version}}` is the only tag a pre-release matches in `ci.yml`, which names no `latest` tag (metadata-action's default adds `latest` only for a stable semver tag; no pre-release has been pushed yet to show it). This document records the workflow's behaviour as current. The spec isn't edited (spec 027 D4). Open for the maintainer; evidence waits for the first pre-release tag ([#21](https://github.com/plainprogrammer/Collector/issues/21)). - [#21](https://github.com/plainprogrammer/Collector/issues/21): three spec 012 criteria have no evidence yet: the major-only tag `1` published by the first `1.0.0` (AC-3.2), the first pre-release publishing only its own tag (AC-3.3), and a live `bin/kamal deploy --skip-push --version X.Y.Z` on a real server (AC-5.2). -- [#122](https://github.com/plainprogrammer/Collector/issues/122): after a restore, `catalog:status` and the admin panel say the art index is "ready" although its file was left behind; the documents tell the operator to press **Build art index** (spec 030 decision 12, AC-2.8). +- A restore leaves the art index file behind (spec 030 decision 3). Until **Build art index** is pressed, the admin panel reads "Index file missing. Build the art index again." and `catalog:status` "Art matching: index file missing …", no longer "ready"; the build fetches no image and writes the file again (spec 039, which fixed [#122](https://github.com/plainprogrammer/Collector/issues/122); spec 030 decision 12, AC-2.8). - **Provenance attestations and SBOMs** wait until the repository is public (ADR 0008; spec 012 Non-Goals). Retention of `sha-*` images, GitHub Releases and changelogs, a version shown in the app, and Dependabot for the base image are not built (spec 012 Non-Goals, Out of Scope; ADR 0009). - A pull request costs two builds on the private repository's Actions minutes, and so does every merge to `main`; the `main` channel can be throttled without changing release tags (ADR 0009 Consequences). If arm64 hosted runners are withdrawn, the matrix entry falls back to QEMU on `ubuntu-latest` as a plan change (ADR 0010). - An account removed by raw SQL leaves its stored upload's attachment row and file behind, which the sweep doesn't treat as an orphan; the application's own deletions run the callbacks (ADR 0022 Consequences; maintainer, 2026-10-20). @@ -262,10 +262,11 @@ CI proves one release step of it: the previous release's published image, seeded - [Spec 030](../specs/030-backup-restore/spec.md): all but decision 11 (the export versus the backup), which is data portability's (its decision 14 and AC-5.7 are superseded in part by spec 031 decision 7). - [Spec 031](../specs/031-upgrade-from-previous-release/spec.md): all. - [Spec 035](../specs/035-ci-shards/spec.md): decisions 2 and 4–8 (the `bin/ci` groups, the shards, the run-time log, the workflow's `checks`, `rspec` and `ci` jobs, the slowest examples) and Story 2; its decision 3 and Story 3 (which branches the pre-push hook runs `bin/ci checks` for) are covered by CLAUDE.md's Claude Code Hooks. It supersedes spec 001 AC-2.5 and Story 2's "CI passes" in part, and spec 031 AC-7.6, AC-8.5 and decision 2 in part. +- [Spec 039](../specs/039-art-index-missing-file/spec.md): what the admin page and `catalog:status` read after a restore leaves the art index file behind (the rules are catalog operations'). - [Spec 036](../specs/036-advisory-response/spec.md): all (the grouped security updates, the daily audit workflow and the response to a red audit; the criteria that need GitHub are evidenced in its `verification.md`). - ADRs [0008](../adr/0008-public-images-from-the-private-repository.md), [0009](../adr/0009-image-tags-and-release-channels.md), [0010](../adr/0010-native-multi-architecture-image-builds.md), [0011](../adr/0011-own-ghcr-cleanup-script.md), [0016](../adr/0016-sqlite-in-every-environment.md), [0022](../adr/0022-stored-import-upload-retention.md), [0024](../adr/0024-backup-with-vacuum-into.md); and, for the image's contents, [0012](../adr/0012-decode-art-images-with-imagemagick.md) and [0013](../adr/0013-active-storage-variants-with-vips.md). - Reference: [`docs/releasing.md`](../releasing.md), [`docs/backup-restore.md`](../backup-restore.md), `README.md` ("Self-hosting", "Backup and restore"). -- Issues [#31](https://github.com/plainprogrammer/Collector/issues/31) (local builds held the OCR engine twice; fixed, guarded by `bin/image-smoke` and `.dockerignore`), [#42](https://github.com/plainprogrammer/Collector/issues/42) (the upgrade proof), [#145](https://github.com/plainprogrammer/Collector/issues/145) (spike: where CI's time went, and the pre-push hook that stopped gating past its timeout; findings in spec 035's [research.md](../specs/035-ci-shards/research.md)), [#109](https://github.com/plainprogrammer/Collector/issues/109), [#132](https://github.com/plainprogrammer/Collector/issues/132) (actions pinned to SHAs) and [#164](https://github.com/plainprogrammer/Collector/issues/164) (the advisory response, spec 036); open: [#21](https://github.com/plainprogrammer/Collector/issues/21), [#122](https://github.com/plainprogrammer/Collector/issues/122). +- Issues [#31](https://github.com/plainprogrammer/Collector/issues/31) (local builds held the OCR engine twice; fixed, guarded by `bin/image-smoke` and `.dockerignore`), [#42](https://github.com/plainprogrammer/Collector/issues/42) (the upgrade proof), [#145](https://github.com/plainprogrammer/Collector/issues/145) (spike: where CI's time went, and the pre-push hook that stopped gating past its timeout; findings in spec 035's [research.md](../specs/035-ci-shards/research.md)), [#109](https://github.com/plainprogrammer/Collector/issues/109), [#132](https://github.com/plainprogrammer/Collector/issues/132) (actions pinned to SHAs), [#164](https://github.com/plainprogrammer/Collector/issues/164) (the advisory response, spec 036) and [#122](https://github.com/plainprogrammer/Collector/issues/122) (the art index read "ready" after a restore; fixed by spec 039); open: [#21](https://github.com/plainprogrammer/Collector/issues/21). ## Distillation log @@ -275,3 +276,4 @@ CI proves one release step of it: the previous release's published image, seeded | 2026-10-24 | Spec 031 (the upgrade proof, the migration guard, the release steps around them) | spec 031's pull request | | 2026-11-01 | Spec 035 (the `bin/ci` groups, the shards and their run-time log, the workflow's `checks`, `rspec` and `ci` jobs, the slowest examples, the measured shard count); its pre-push hook (decision 3, Story 3) stays in CLAUDE.md, which has the development environment's facts | [#149](https://github.com/plainprogrammer/Collector/pull/149) and spec 035's follow-up pull request | | 2026-11-03 | Spec 036 (the Ruby gems' security updates as one group, the daily advisory audit workflow, the response to a red audit, the two Named constants rows); the criteria that need GitHub stay in its `verification.md` | [#163](https://github.com/plainprogrammer/Collector/pull/163) | +| 2026-11-06 | Spec 039 (what the admin page and `catalog:status` read after a restore leaves the art index file behind; #122 fixed) | [#175](https://github.com/plainprogrammer/Collector/pull/175) | diff --git a/docs/roadmap.md b/docs/roadmap.md index c98b162d..28f42fcd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -98,6 +98,7 @@ Taken between specs. - [#145](https://github.com/plainprogrammer/Collector/issues/145): spike: why `bin/ci` grew from minutes to 20–30, and why the pre-push hook stopped gating past its timeout. **Done 2026-10-31:** findings in [research.md](specs/035-ci-shards/research.md); the fixes in [#147](https://github.com/plainprogrammer/Collector/pull/147) (the parity example) and [#148](https://github.com/plainprogrammer/Collector/pull/148) (the hooks block on a timeout). - Spec 035, from #145: `bin/ci` groups, used by GitHub's RSpec shards and by the pre-push hook, which skips RSpec for a documentation-only branch. **Shipped 2026-11-01:** [spec 035](specs/035-ci-shards/spec.md), [PR #149](https://github.com/plainprogrammer/Collector/pull/149), with its verification, review fixes and four shards in a follow-up pull request; GitHub's verdict went from a median 348 s to 140 s. - [#164](https://github.com/plainprogrammer/Collector/issues/164): dependency advisories: Dependabot's security updates for the Ruby gems as one group, a daily scheduled run of both audits on `main`, and the response to a red audit written into the release-and-deploy area doc (found on 2026-11-03, when five Dependabot pull requests failed on advisories for gems that no Dependabot pull request covered). **Done 2026-11-03:** [spec 036](specs/036-advisory-response/spec.md), [PR #163](https://github.com/plainprogrammer/Collector/pull/163); the maintainer's post-merge checks are in its `verification.md`. +- [#122](https://github.com/plainprogrammer/Collector/issues/122): the art index panel and `catalog:status` say "ready" when the finished build's index file is missing (after a restore, or a lost directory). Done when its pull request merges: [spec 039](specs/039-art-index-missing-file/spec.md). ## Parked until after 1.0 diff --git a/docs/specs/039-art-index-missing-file/screenshots/01-art-index-ready-light.jpg b/docs/specs/039-art-index-missing-file/screenshots/01-art-index-ready-light.jpg new file mode 100644 index 00000000..580976e3 Binary files /dev/null and b/docs/specs/039-art-index-missing-file/screenshots/01-art-index-ready-light.jpg differ diff --git a/docs/specs/039-art-index-missing-file/screenshots/02-art-index-ready-dark.jpg b/docs/specs/039-art-index-missing-file/screenshots/02-art-index-ready-dark.jpg new file mode 100644 index 00000000..0ea87d57 Binary files /dev/null and b/docs/specs/039-art-index-missing-file/screenshots/02-art-index-ready-dark.jpg differ diff --git a/docs/specs/039-art-index-missing-file/screenshots/03-art-index-missing-light.jpg b/docs/specs/039-art-index-missing-file/screenshots/03-art-index-missing-light.jpg new file mode 100644 index 00000000..faca6a3a Binary files /dev/null and b/docs/specs/039-art-index-missing-file/screenshots/03-art-index-missing-light.jpg differ diff --git a/docs/specs/039-art-index-missing-file/screenshots/04-art-index-missing-dark.jpg b/docs/specs/039-art-index-missing-file/screenshots/04-art-index-missing-dark.jpg new file mode 100644 index 00000000..142c349e Binary files /dev/null and b/docs/specs/039-art-index-missing-file/screenshots/04-art-index-missing-dark.jpg differ diff --git a/docs/specs/039-art-index-missing-file/spec.md b/docs/specs/039-art-index-missing-file/spec.md index 5c43a02d..0c79d03e 100644 --- a/docs/specs/039-art-index-missing-file/spec.md +++ b/docs/specs/039-art-index-missing-file/spec.md @@ -6,6 +6,7 @@ **Last Updated:** 2026-11-03 **Branch:** `039-art-index-missing-file` **Issue:** [#122](https://github.com/plainprogrammer/Collector/issues/122) +**Frozen:** the current rules are in [`catalog-operations.md`](../../architecture/catalog-operations.md) and [`release-and-deploy.md`](../../architecture/release-and-deploy.md) (whole spec); see the [specs index](../README.md) --- diff --git a/docs/specs/039-art-index-missing-file/verification.md b/docs/specs/039-art-index-missing-file/verification.md new file mode 100644 index 00000000..606e3253 --- /dev/null +++ b/docs/specs/039-art-index-missing-file/verification.md @@ -0,0 +1,62 @@ +# Verification: Feature 039, A Finished Art Build Whose Index File Is Missing Isn't "Ready" + +**Spec:** [spec.md](spec.md) v1.0.1 · **Plan:** [plan.md](plan.md) · **Issue:** [#122](https://github.com/plainprogrammer/Collector/issues/122) · **Pull request:** [#175](https://github.com/plainprogrammer/Collector/pull/175) + +## Red, then green, per phase + +| Phase | Commit | Red (before the code change) | Green | +|---|---|---|---| +| 1. `MTG::Art::Index.file?`, `MTG::ArtBuild#index_file_present?` | `446b507` | `index_spec.rb` "knows whether a file of its name is there" and `art_build_spec.rb` "#index_file_present?" failed, neither method existing yet (the latter: "expected … to respond to `index_file_present?`") (35 examples, 2 failures) | 35 examples, 0 failures | +| 2. The panel's `:index_missing` state | `f73f537` | The four new missing-state examples (AC-1.1–1.3, both AC-1.5, AC-1.8) failed, reading `:ready` / "Ready."; the AC-1.4, AC-1.6 and AC-1.7 guards passed (94 examples, 4 failures) | 94 examples, 0 failures; with the other readers of the art line and panel (`art_spec.rb`, `catalog_rake_spec.rb`, `scryfall/source_spec.rb`, `backup_restored_art_spec.rb`, `admin_catalog_core_spec.rb`): 150 examples, 0 failures | +| 3. The status line | `5712f67` | AC-2.1's two examples failed, the line reading "Art matching: ready; …" (24 examples, 2 failures) | 55 examples, 0 failures (`art_spec.rb`, `catalog_rake_spec.rb`, `scryfall/source_spec.rb`) | +| 4. Building again restores "Ready." | `474ee49` | With Phase 1's `operation.rb` and `art.rb` put back: AC-3.2 failed (`expected: :index_missing`) and AC-1.8 likewise; AC-3.1 passed, a guard (the controller is unchanged, as the spec says) | 3 examples, 0 failures; `build_spec.rb`, `catalogs_spec.rb`, `backup_restored_art_spec.rb`: 66 examples, 0 failures | +| 5. Documents | `fa3a0f4` | Reading-verified (below) | | + +Full local `bin/ci` at `474ee49` (the last code commit; later commits are documents and images): every step passed, RSpec 2435 examples, 0 failures, 1 pending, in 5m59s. The pre-push hook's full `bin/ci` (it blocks on failure) let the push of `80aad73` through. + +## Acceptance criteria + +| AC | Evidence | +|---|---| +| AC-1.1 | `spec/models/mtg/art/operation_spec.rb` "says the index file is missing, shows the build's facts and can be built again" | +| AC-1.2 | Same example: labels in order `Finished, Index file, Artworks indexed, Without an image, Failed images` and their values | +| AC-1.3 | Same example: `startable?` true, `meter` nil, `failed_job?` false, `record_running?` false; the request example of AC-1.8 finds no `poll` controller | +| AC-1.4 | `operation_spec.rb` "is ready while the named file is there"; the updated AC-4.7 example also checks no "Index file" fact | +| AC-1.5 | `operation_spec.rb` "reads a row with no file name as missing, with none recorded" and "reads a name of another shape as missing and shows it as stored"; that nothing outside `index/` is examined is `MTG::Art::Index.file?`'s `NAME` check, proven in `index_spec.rb` with `../../config/master.key` | +| AC-1.6 | `operation_spec.rb` "is queued while a build job waits", "is building while a later build runs", "is failed, worded as before, when the latest build failed" | +| AC-1.7 | `operation_spec.rb` "looks at no file, whatever the latest build names" (art matching off; `MTG::Art::Index.file?` spied, never called) | +| AC-1.8 | `spec/requests/admin/catalogs_spec.rb` "says a finished build's index file is missing, with its facts and an enabled button, and doesn't poll"; a member's 404 is the file's existing generic example | +| AC-2.1 | `spec/models/mtg/art_spec.rb` "says the index file is missing, and what to press" and "says no file name was recorded for a finished build without one" | +| AC-2.2 | `art_spec.rb` "reports a finished build's counts and index" (now with a real file) | +| AC-2.3 | `art_spec.rb`'s existing running, stalled, interrupted, failed, off and no-build examples, unchanged and green | +| AC-3.1 | `catalogs_spec.rb` "queues one build when Build art index is pressed with the index file missing" | +| AC-3.2 | `spec/models/mtg/art/build_spec.rb` "writes a missing index file again without an image request, and reads ready again" (ImageMagick present; every request blocked after `WebMock.reset!`) | +| AC-4.1 | `docs/architecture/catalog-operations.md`: Glossary "Ready (art index)"; the art index table's two Finished rows; the status-line paragraph; Accepted limits (judged by name only); Sources (spec 039, #122). Read on 2026-11-05 | +| AC-4.1a | `docs/architecture/release-and-deploy.md`: Neighbours line, the #122 limit reworded as fixed, Sources (spec 039; #122 moved from open to fixed). Read on 2026-11-05 | +| AC-4.2 | `docs/specs/README.md`: the 039 row's Supersedes names 015 AC-4.1, 011 AC-3.11 and 030 decision 12, each in part; the 015, 011 and 030 rows' Superseded-by cells name 039 (the 030 row's text moved from a stray seventh cell into Superseded by). No shipped spec edited (`git diff origin/main -- 'docs/specs/0[0-2]*' 'docs/specs/03[0-8]*'` is empty). Read on 2026-11-05 | +| AC-4.3 | `README.md`'s Restore paragraph and "After a restore" checklist say the page and `catalog:status` read "Index file missing" until **Build art index** is pressed; `docs/backup-restore.md` and `lib/collector/backup/restore.rb` unchanged. Read on 2026-11-05 | + +## Demonstration + +`/admin/catalog` with art matching on, the art build row seeded by a scratch script into the worktree's dev database (an empty index written through `MTG::Art::Index.write!` for "ready", deleted for "missing"): [screenshots/](screenshots/) 01–04, light and dark. `catalog:status[mtg]` output for both states is in the pull request body. + +## Implementation review + +SPEC-ALIGNED WITH MINOR GAPS (sdd-review Mode B, Opus, read-only, 2026-11-06, on `80aad73`); the minor gaps it named are closed or ruled below, so the feature stands spec-aligned. + +Every AC was covered by a test or a reading (the table above matches the reviewer's). FR-1 to FR-4, the NFRs, decisions 1 to 6, the error-scenario table and the Supersedes section match the code; FR-4 held (nothing under `app/controllers`, `app/views`, `app/helpers` or `app/models/catalog` changed). No blocking or major findings. The minor ones: + +| # | Finding | Handling | +|---|---|---| +| 1 | README quoted the panel's words for `catalog:status` | Fixed in `7633920`: it quotes "Art matching: index file missing" | +| 2 | AC-2.2's guard matched substrings only | Fixed in `7633920`: the whole ready line is compared | +| 3 | AC-1.6 had no interrupted example behind a missing-file build | Fixed in `7633920`: one example (stalled takes the same `running?` branch) | +| 4 | `verification.md` uncommitted; `Gemfile.lock` noise | This commit; the local arm64-darwin `Gemfile.lock` change stays uncommitted | +| 5 | The settings-drift follow-up issue is unfiled | Filed by the coordinator as [#176](https://github.com/plainprogrammer/Collector/issues/176) | +| 6 | The upgrade seeds' `art-index-upgrade.bin.gz` reads as missing | No action: accepted by the spec's Interactions section; the upgrade proof checks neither page | + +A permission error reading as missing rests on `File.file?` returning false on a failed `stat`; no example forces one (not portable in CI). + +## Distillation + +2026-11-06 (`distill-feature`): the rules were written into [catalog operations](../../architecture/catalog-operations.md) (Glossary "Ready (art index)", the art index table's two Finished rows, the status-line paragraph, an accepted limit, Sources) and [release and deploy](../../architecture/release-and-deploy.md) (Neighbours line, the #122 limit reworded as fixed, Sources) in `fa3a0f4`; `fef8f9b` added both docs' Distillation log rows and set the index row to Frozen by both, plan retirement owed; `6aa432a` added the spec's frozen line. CLAUDE.md only points to the area doc and needed no change. No ADR is owed. The plan is retired after the merge. diff --git a/docs/specs/README.md b/docs/specs/README.md index 1ae17dae..25ebc560 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -31,11 +31,11 @@ A plan is deleted from the tree in a commit of its own after its feature's pull | [008 Card Scanner Phase 2 Spike — Card Detection and Art Matching](008-card-scanner-phase-2-spike/spec.md) | Shipped | Scanner | present | | | | [009 Card Scanner — Confirm and Add, and Detection on the Photo Path](009-card-scanner-confirm-flow/spec.md) | Shipped | Scanner | present | 007 AC-1.7 and FR-1's "reachable by URL only", AC-3.2, AC-3.3, AC-3.10, AC-4.2, FR-1's "no tenant data", FR-3's "only recognised text", FR-4's "no finish" (its Supersedes list) | AC-2.1, AC-2.3, AC-2.4, AC-5.1, AC-5.2, AC-5.5, FR-3 and FR-5, art evidence: 011 (its Supersedes list). AC-8.2, the collection page head: 026 ("Add by number" before "Scan cards", which stays). The header's navigation for a collector: 029 (gains Locations after Scan, decision 8). Its lots merge by the identity of spec 004; the copies a scan adds are unplaced and join the unplaced lot: 032 (Story 7) | | [010 Card Scanner Art Spike — The Index on a Phone, and Art on Live Captures](010-card-scanner-art-spike/spec.md) | Shipped | Scanner | present | | | -| [011 Card Scanner — Art Matching on Live Capture](011-card-scanner-art-matching/spec.md) | Frozen in part: FR-2's build run, by [catalog operations](../architecture/catalog-operations.md) | Scanner; [catalog operations](../architecture/catalog-operations.md) (FR-2's build run) | present | 007 FR-3, match results sent (amended in place, 007 v2.2.0). 007 FR-6, measurement mode records art. 009 AC-2.1, AC-2.3, AC-2.4, AC-5.1, AC-5.2, AC-5.5, FR-3 and FR-5, art evidence (its Supersedes list) | AC-3.2, a build that starts while another build job's retry waits is skipped: 024 (AC-2.1). AC-3.2, AC-3.4, AC-3.6, AC-3.8, AC-3.11, the left-behind build and rate-limit waits: amended in place for #73, #74, #79 and #81 (v1.1.4–v1.3.0), stating spec 015's AC-4.10 and AC-4.11 | +| [011 Card Scanner — Art Matching on Live Capture](011-card-scanner-art-matching/spec.md) | Frozen in part: FR-2's build run, by [catalog operations](../architecture/catalog-operations.md) | Scanner; [catalog operations](../architecture/catalog-operations.md) (FR-2's build run) | present | 007 FR-3, match results sent (amended in place, 007 v2.2.0). 007 FR-6, measurement mode records art. 009 AC-2.1, AC-2.3, AC-2.4, AC-5.1, AC-5.2, AC-5.5, FR-3 and FR-5, art evidence (its Supersedes list) | AC-3.2, a build that starts while another build job's retry waits is skipped: 024 (AC-2.1). AC-3.2, AC-3.4, AC-3.6, AC-3.8, AC-3.11, the left-behind build and rate-limit waits: amended in place for #73, #74, #79 and #81 (v1.1.4–v1.3.0), stating spec 015's AC-4.10 and AC-4.11. AC-3.11, the art line says "ready" only when the file is there: 039 | | [012 Build and Publish Collector Images](012-ghcr-registry-publishing/spec.md) | Frozen, by [release and deploy](../architecture/release-and-deploy.md) | Release and deploy | retired in `a41bd089232414faafa7f6623b5e79d10997b8f6`; read it with `git show a41bd089232414faafa7f6623b5e79d10997b8f6^:docs/specs/012-ghcr-registry-publishing/plan.md` | 001's Compose file that builds the image from a checkout and Kamal's placeholder registry (FR-4, FR-5; AC-1.5, AC-5.1) | | | [013 Clean Up Untagged GHCR Package Versions](013-ghcr-untagged-cleanup/spec.md) | Frozen, by [release and deploy](../architecture/release-and-deploy.md) | Release and deploy | retired in `5a1e2eb43ff1b8c4b66ca24f3573f96013f64d2a`; read it with `git show 5a1e2eb43ff1b8c4b66ca24f3573f96013f64d2a^:docs/specs/013-ghcr-untagged-cleanup/plan.md` | | | | [014 Active Storage Variants with vips](014-active-storage-vips/spec.md) | Frozen, by [release and deploy](../architecture/release-and-deploy.md) | CLAUDE.md (R5); release and deploy (the image's libvips) | retired in `4691f7b64cbca464dae4c153f145a18f404e6f98`; read it with `git show 4691f7b64cbca464dae4c153f145a18f404e6f98^:docs/specs/014-active-storage-vips/plan.md` | | | -| [015 Admin Catalog Operations and Jobs](015-admin-catalog-and-jobs/spec.md) | Frozen, by [catalog operations](../architecture/catalog-operations.md) | [Catalog operations](../architecture/catalog-operations.md) | retired in `6c4dd4fb5c247d6aeb5739e3ae9e096345309d0e`; `git show 6c4dd4fb5c247d6aeb5739e3ae9e096345309d0e^:docs/specs/015-admin-catalog-and-jobs/plan.md` | 002 FR-4, the 6-hour "already running" rule (AC-3.3, AC-3.4). 002 AC-1.11 and 004 AC-6.5, the not-loaded notice (AC-1.1, AC-1.2) | AC-2.16, a run skipped for another job's retry: 024 (amended in place, v1.3.1). AC-3.3 and AC-4.10, the starting refresh or build is skipped while another job's retry waits: 024 (AC-1.7, AC-2.1). Glossary, AC-3.2, AC-3.3, AC-3.4, AC-3.9, AC-4.1, AC-4.10, AC-4.11, AC-4.12, AC-6.5, FR-1, FR-2 and Error Scenarios, left-behind runs and builds, attempts, queued to retry: amended in place for #60, #61, #73, #74, #79 and #80 (v1.2.0–v1.3.0) | +| [015 Admin Catalog Operations and Jobs](015-admin-catalog-and-jobs/spec.md) | Frozen, by [catalog operations](../architecture/catalog-operations.md) | [Catalog operations](../architecture/catalog-operations.md) | retired in `6c4dd4fb5c247d6aeb5739e3ae9e096345309d0e`; `git show 6c4dd4fb5c247d6aeb5739e3ae9e096345309d0e^:docs/specs/015-admin-catalog-and-jobs/plan.md` | 002 FR-4, the 6-hour "already running" rule (AC-3.3, AC-3.4). 002 AC-1.11 and 004 AC-6.5, the not-loaded notice (AC-1.1, AC-1.2) | AC-2.16, a run skipped for another job's retry: 024 (amended in place, v1.3.1). AC-3.3 and AC-4.10, the starting refresh or build is skipped while another job's retry waits: 024 (AC-1.7, AC-2.1). Glossary, AC-3.2, AC-3.3, AC-3.4, AC-3.9, AC-4.1, AC-4.10, AC-4.11, AC-4.12, AC-6.5, FR-1, FR-2 and Error Scenarios, left-behind runs and builds, attempts, queued to retry: amended in place for #60, #61, #73, #74, #79 and #80 (v1.2.0–v1.3.0). AC-4.1, the art index's states gain "index file missing": 039 | | [016 Do Scryfall's Terms Permit a Hosted Instance?](016-scryfall-hosting-terms/research.md) (research) | Shipped | Catalog core | none | | | | [017 Export Format](017-export-format/spec.md) | Frozen, by [data portability](../architecture/data-portability.md) | Data portability | retired in `d586ff6f117b878fcb40f718e07dd8b832dc0dbc`; `git show d586ff6f117b878fcb40f718e07dd8b832dc0dbc^:docs/specs/017-export-format/plan.md` | | Decision 14's last sentence, AC-3.3's expectation and its version-history sentence, the Out of Scope line on locations: 029 (locations make version 2, lots in locations version 3; ADR 0023). Lot identity (five values) and the consequences about a lot in a location: 032 (six values and format version 3) | | [018 Content Notice Footer](018-content-notice-footer/spec.md) | Shipped | Catalog core | present | 002 AC-3.2 and 004 AC-8.2, the card page's attribution: moved to the footer (FR-3) | | @@ -50,7 +50,7 @@ A plan is deleted from the tree in a commit of its own after its feature's pull | [027 Area Docs Hold the Current Architecture, Starting with Catalog Operations](027-architecture-area-docs/spec.md) | Shipped | All (the documentation process) | retired in `c1ef5f0138c6898ab171fe6315281e6fed5c86c2`; `git show c1ef5f0138c6898ab171fe6315281e6fed5c86c2^:docs/specs/027-architecture-area-docs/plan.md` | | | | [028 Apply an Import](028-apply-import/spec.md) | Frozen, by [data portability](../architecture/data-portability.md) and [release and deploy](../architecture/release-and-deploy.md) | Data portability; release and deploy (stored upload retention) | retired in `ee4c4fa40e2223e2d81299885f8a0cabe7771956`; `git show ee4c4fa40e2223e2d81299885f8a0cabe7771956^:docs/specs/028-apply-import/plan.md` | 025 decision 6, "never stored", for a file that can be applied (decision 10). 025 decision 9, one point: a price that does not fit the cents column is a problem row (1.3.4) | Inputs and Non-Goals (version 1 only), Terms, decision 2 (the fingerprint), decision 5 (replace, lots and the profile only), decision 6 (the checkbox), decision 7 and AC-1.8 (working state cleared only when a lot is written), decision 11 and the notice's counts, Story 6's round trip: 029 (decisions 10 and 11, Story 9). Terms, decision 2 (the plan and fingerprint), the lot upsert's key, the confirmation and the notice: 032 (reads versions 1 to 3; places in the plan and the fingerprint; a location is removed only with the lots in it). AC-6.4, the round trip of each valid example: 032 (AC-11.14, a version 1 or 2 example re-exports as version 3) | | [029 Manage Locations](029-manage-locations/spec.md) | Frozen, by [collection and lots](../architecture/collection-and-lots.md) and [data portability](../architecture/data-portability.md) | [Collection and lots](../architecture/collection-and-lots.md) (locations); [data portability](../architecture/data-portability.md) (export format version 2; the preview and the apply read versions 1 and 2 and cover locations); [design system](../design-system/README.md) (navigation) | retired in `88a52cf7f4b84562d40db23bf157de58fceab2c2`; `git show 88a52cf7f4b84562d40db23bf157de58fceab2c2^:docs/specs/029-manage-locations/plan.md` | ADR 0020 consequences (a lot in a location raises the format to version 3; locations make version 2; a new ADR records it). 017 decision 14, AC-3.3 and its version-history sentence; the format document's "Locations will add where each lot is kept" sentence. Roadmap item 10. 022 (writes version 2). 025 (reads versions 1 and 2; AC-3.2; Locations section). 028 (reads versions 1 and 2; the plan, fingerprint, confirmation, transaction and notice cover locations; decision 7 and AC-1.8 clear working state only when the apply writes lots). 022 decision 6 and 026 (More's rows gain Locations). 004, 009, 023 (the header gains Locations) | Decision 3 (removing a location never removes copies; its confirmation page), Non-Goals (counts per location): 032 (a location that holds copies can't be removed; the refusal page; counts on the locations page). Decision 10 (reads versions 1 and 2): 032 (reads 1 to 3). AC-9.15, the round trip of each valid example: 032 (AC-11.14, a version 1 or 2 example re-exports as version 3) | -| [030 Back Up and Restore an Instance](030-backup-restore/spec.md) | Frozen, by [data portability](../architecture/data-portability.md) and [release and deploy](../architecture/release-and-deploy.md) | Release and deploy (backup and restore commands, the procedure); data portability (the export-versus-backup guidance) | retired in `c8706d3f9b7eb33b9f13388be5a15bec33f3ce76`; `git show c8706d3f9b7eb33b9f13388be5a15bec33f3ce76^:docs/specs/030-backup-restore/plan.md` | | | decision 14 and AC-5.7, the newer-image leg: 031 (decision 7) | +| [030 Back Up and Restore an Instance](030-backup-restore/spec.md) | Frozen, by [data portability](../architecture/data-portability.md) and [release and deploy](../architecture/release-and-deploy.md) | Release and deploy (backup and restore commands, the procedure); data portability (the export-versus-backup guidance) | retired in `c8706d3f9b7eb33b9f13388be5a15bec33f3ce76`; `git show c8706d3f9b7eb33b9f13388be5a15bec33f3ce76^:docs/specs/030-backup-restore/plan.md` | | decision 14 and AC-5.7, the newer-image leg: 031 (decision 7). Decision 12, "not fixed here" (fixed): 039 | | [031 Prove an Upgrade from the Previous Release in CI](031-upgrade-from-previous-release/spec.md) | Frozen, by [release and deploy](../architecture/release-and-deploy.md) | [Release and deploy](../architecture/release-and-deploy.md) (the upgrade proof, the release steps); data portability (none: no collector-owned data) | retired in `224105d82730320af5e64e726dfc653f6920e402`; `git show 224105d82730320af5e64e726dfc653f6920e402^:docs/specs/031-upgrade-from-previous-release/plan.md` | 030 decision 14 and AC-5.7, the newer-image leg: automated where the previous release has the backup task. Delivers the proof 030 left owed to #42 | AC-7.6, AC-8.5 and decision 2 in part, the guard and the full-history checkout in the `ci` job: 035 (decision 5) | | [032 Put a Lot in a Location](032-lot-in-a-location/spec.md) | Frozen, by [collection and lots](../architecture/collection-and-lots.md) and [data portability](../architecture/data-portability.md) | [Collection and lots](../architecture/collection-and-lots.md) (a lot's location, moving copies); [data portability](../architecture/data-portability.md) (export format version 3; the preview and the apply cover places); [design system](../design-system/README.md) (the card page) | retired in `91700487e563dd2821dc5cb5ea294f0af104061e`; `git show 91700487e563dd2821dc5cb5ea294f0af104061e^:docs/specs/032-lot-in-a-location/plan.md` | ADR 0019 (the open question for locations) and ADR 0023 (deleting a location; its "reads versions 1 and 2" bullet). 004 FR-6, AC-7.4, AC-9.3, AC-10.2, the add and edit forms and the card page's Your copies table. 006 (bulk Set condition and Undo keep places). 009, 026 (their copies are unplaced). 017 (lot identity, a lot in a location). 022 (writes version 3). 025 (reads versions 1 to 3, six-value matching, "This instance reads version 3.", decision 9's problem-row reasons). 028 (reads versions 1 to 3; places in the plan and fingerprint; AC-6.4's round trip). 029 decision 3, decision 10, AC-9.15 and its Non-Goal on counts | Decision 5 and Story 7 (AC-7.2), for number entry only (a line may carry a place): 034 Non-Goals on #46, decision 5 and AC-4.4 (the grid and table show no place): 033 | | [033 Show the Location on Tiles and Rows](033-location-on-tiles-and-rows/spec.md) | Frozen in part: all but decision 5 as it applies to rows ([#142](https://github.com/plainprogrammer/Collector/issues/142)), by [collection and lots](../architecture/collection-and-lots.md) | [Collection and lots](../architecture/collection-and-lots.md) (the grid and the table); [design system](../design-system/README.md) (ItemTile, CollectionTable) | retired in `fb8de443936f0fd8446c8cb1d0654faf63156cb6`; `git show fb8de443936f0fd8446c8cb1d0654faf63156cb6^:docs/specs/033-location-on-tiles-and-rows/plan.md` | 032 Non-Goals on #46, decision 5 and AC-4.4. 004 AC-11.2 and the Non-Goal on a location line. 006 AC-2.1 and AC-2.8 | AC-1.8 (a tile shows all its places) under a location filter, and FR-3's `exists?` read: 037 (decision 3, NFR Performance) | @@ -59,5 +59,5 @@ A plan is deleted from the tree in a commit of its own after its feature's pull | [036 Advisory Response for Locked Dependencies](036-advisory-response/spec.md) | Frozen, by [release and deploy](../architecture/release-and-deploy.md) | [Release and deploy](../architecture/release-and-deploy.md) (the Dependabot configuration, a scheduled audit workflow) | retired in `3ebd563429e487d6f03f5bf33c2490664a7d353b`; `git show 3ebd563429e487d6f03f5bf33c2490664a7d353b^:docs/specs/036-advisory-response/plan.md` | | | | [037 Filter the Collection by Location](037-filter-collection-by-location/spec.md) | Approved | [Collection and lots](../architecture/collection-and-lots.md) (the collection page, bulk mode's selection); [design system](../design-system/README.md) (FilterSelect) | present | | Spec 033 AC-1.8 and FR-3; spec 004's filter-bar note; spec 006 FR-4's selection context, as stated in 037's Supersedes | | | [038 Store Currency per Account](038-currency-per-account/spec.md) | Approved | [Collection and lots](../architecture/collection-and-lots.md) (price paid's currency); [data portability](../architecture/data-portability.md) (the export's currency, the preview and the apply) | present | 004 FR-6 (the instance currency; the boot check and "converts nothing" stay). 006 (the table's price in the instance currency). 017 FR-4 and decision 13 (the currency is the instance's). 022 decision 4 (its source). 025 decision 3 and its `other_currency` row (a file in another currency is refused). 028 AC-6.4 and decisions 2, 5, 11, 12 (the apply writes the currency too). 030 (a restore shows another symbol). 032 (the format version after 3 is #64's). North star hosted-door note on currency | | -| [039 A Finished Art Build Whose Index File Is Missing Isn't "Ready"](039-art-index-missing-file/spec.md) | Approved | [Catalog operations](../architecture/catalog-operations.md) (the art index panel and the art line of `catalog:status`) | present | 015 AC-4.7 and 011 AC-3.11, as far as a finished build reads "ready" whatever the volume holds. 030 decision 12 ("not fixed here") | | +| [039 A Finished Art Build Whose Index File Is Missing Isn't "Ready"](039-art-index-missing-file/spec.md) | Frozen, by [catalog operations](../architecture/catalog-operations.md) and [release and deploy](../architecture/release-and-deploy.md) | [Catalog operations](../architecture/catalog-operations.md) (the art index panel and the art line of `catalog:status`); [release and deploy](../architecture/release-and-deploy.md) (what a restore reads until the build) | retirement owed | 015 AC-4.1 in part, the art index's states gain "index file missing" and "ready" needs the file. 011 AC-3.11 in part, the art line says "ready" only when the file is there. 030 decision 12 in part, "not fixed here" (fixed) | | | [040 Scan a Sitting into a Location](040-sitting-into-location/spec.md) | Approved | [Collection and lots](../architecture/collection-and-lots.md) (the scanner adds placed copies); scanner (no area doc) | present | 009 Non-Goals "Sitting-wide defaults" and "Locations", for a location only. 032 decision 5 and Story 7 (AC-7.2), for the scanner only: a sitting may carry a place | | diff --git a/spec/models/mtg/art/build_spec.rb b/spec/models/mtg/art/build_spec.rb index 8d6e255b..02ef792c 100644 --- a/spec/models/mtg/art/build_spec.rb +++ b/spec/models/mtg/art/build_spec.rb @@ -269,4 +269,20 @@ def self.decode(_path) = raise(Errno::ENOSPC) expect(run_build).to be_nil expect(MTG::ArtBuild.count).to eq(0) end + + it "writes a missing index file again without an image request, and reads ready again (spec 039 AC-3.2)", :aggregate_failures do + printing(art_a, released_on: Date.new(2005, 1, 1)) + serve("#{art_a}-2005-1") + name = run_build.index_file + FileUtils.rm_rf(MTG::Art.root) # the index and the cached image, as a restore onto a fresh volume leaves nothing + expect(MTG::Art::Operation.new("mtg").state).to eq(:index_missing) + expect(MTG::Art.status_line).to start_with("Art matching: index file missing") + + WebMock.reset! # no stub is left: a request would raise, and would be counted + expect(run_build(job_id: "job-2")).to have_attributes(status: "finished", fetched_count: 0, index_file: name) + expect(a_request(:any, /.*/)).not_to have_been_made + expect(MTG::Art::Index.dir.join(name)).to be_file + expect(MTG::Art::Operation.new("mtg").state).to eq(:ready) + expect(MTG::Art.status_line).to start_with("Art matching: ready") + end end diff --git a/spec/models/mtg/art/index_spec.rb b/spec/models/mtg/art/index_spec.rb index e9086a8d..d0a74196 100644 --- a/spec/models/mtg/art/index_spec.rb +++ b/spec/models/mtg/art/index_spec.rb @@ -41,4 +41,19 @@ described_class.write!("v1", records) expect([ described_class.built_for?("v1"), described_class.built_for?("v2") ]).to eq([ true, false ]) end + + it "knows whether a file of its name is there (spec 039 decision 4)", :aggregate_failures do + path = described_class.write!("default-cards-1", []) + expect(described_class.file?(path.basename.to_s)).to be(true) + [ "art-index-default-cards-1-0123456789abcdef-3.bin.gz", nil, "", "art-index-upgrade.bin.gz", "../../config/master.key" ].each do |name| + expect(described_class.file?(name)).to be(false), "expected #{name.inspect} to be missing" + end + + path.delete + expect(described_class.file?(path.basename.to_s)).to be(false) + + described_class.write!("default-cards-1", []) + FileUtils.rm_rf(MTG::Art.root) + expect(described_class.file?(path.basename.to_s)).to be(false) + end end diff --git a/spec/models/mtg/art/operation_spec.rb b/spec/models/mtg/art/operation_spec.rb index c6682e7a..90c29861 100644 --- a/spec/models/mtg/art/operation_spec.rb +++ b/spec/models/mtg/art/operation_spec.rb @@ -20,6 +20,15 @@ def quiet_build(job_id, **attributes) summary: "Art matching is off. Set COLLECTOR_MTG_ART_MATCHING=true to turn it on.") expect(operation.start).to be(false) end + + it "looks at no file, whatever the latest build names (spec 039 AC-1.7)", :aggregate_failures do + loaded + build(index_file: "art-index-default-cards-1-0123456789abcdef-9.bin.gz") + allow(MTG::Art::Index).to receive(:file?) + + expect(operation.state).to eq(:off) + expect(MTG::Art::Index).not_to have_received(:file?) + end end context "with art matching on", :art_matching do @@ -396,7 +405,7 @@ def stalled_build(**attributes) it "shows the build as it ended when the job finished before the queue was read" do loaded - quiet_build(job.active_job_id).finish!(:finished) + quiet_build(job.active_job_id, index_file: art_index_name).finish!(:finished) finish_job(job) expect(operation.state).to eq(:ready) @@ -405,11 +414,12 @@ def stalled_build(**attributes) it "shows a finished build's counts and finish time (AC-4.7)", :aggregate_failures do loaded - build(indexed_count: 31_904, without_image_count: 12, failed_count: 3) + build(index_file: art_index_name, indexed_count: 31_904, without_image_count: 12, failed_count: 3) expect(operation).to have_attributes(state: :ready, summary: "Ready.", meter: nil, startable?: true) expect(operation.facts.to_h { |fact| [ fact.label, fact.value ] }) .to include("Artworks indexed" => 31_904, "Without an image" => 12, "Failed images" => 3, "Finished" => be_a(Time)) + expect(operation.facts.map(&:label)).not_to include("Index file") end it "shows why a build failed, its error as the detail, and the index still in use (AC-4.8, issue #84)", :aggregate_failures do @@ -439,10 +449,82 @@ def stalled_build(**attributes) it "ignores a build skipped because another was running, as the status line does" do loaded - build(indexed_count: 9, started_at: 2.hours.ago) + build(index_file: art_index_name, indexed_count: 9, started_at: 2.hours.ago) build(status: "skipped", message: "already running", started_at: 1.hour.ago) expect(operation.state).to eq(:ready) end + + context "with a finished build whose index file is gone (spec 039)" do + let(:gone) { "art-index-default-cards-1-0123456789abcdef-9.bin.gz" } + + def facts = operation.facts.to_h { |fact| [ fact.label, fact.value ] } + + it "says the index file is missing, shows the build's facts and can be built again (AC-1.1, AC-1.2, AC-1.3)", :aggregate_failures do + loaded + build(index_file: gone, indexed_count: 9, without_image_count: 1, failed_count: 2) + + expect(operation).to have_attributes(state: :index_missing, summary: "Index file missing. Build the art index again.", + startable?: true, meter: nil, failed_job?: false, record_running?: false) + expect(operation.facts.map(&:label)).to eq([ "Finished", "Index file", "Artworks indexed", "Without an image", "Failed images" ]) + expect(facts).to include("Finished" => be_a(Time), "Index file" => gone, "Artworks indexed" => 9, "Without an image" => 1, + "Failed images" => 2) + end + + it "is ready while the named file is there (AC-1.4)", :aggregate_failures do + loaded + build(index_file: art_index_name) + + expect(operation).to have_attributes(state: :ready, summary: "Ready.") + end + + it "reads a row with no file name as missing, with none recorded (AC-1.5)", :aggregate_failures do + loaded + build(index_file: nil) + + expect(operation.state).to eq(:index_missing) + expect(facts["Index file"]).to eq("none recorded") + end + + it "reads a name of another shape as missing and shows it as stored (AC-1.5)", :aggregate_failures do + loaded + build(index_file: "../../x") + + expect(operation.state).to eq(:index_missing) + expect(facts["Index file"]).to eq("../../x") + end + + it "is queued while a build job waits (AC-1.6)" do + loaded + build(index_file: gone) + queue_job(MTG::Art::BuildJob) + + expect(operation.state).to eq(:queued) + end + + it "is building while a later build runs (AC-1.6)" do + loaded + build(index_file: gone, started_at: 2.hours.ago) + build(status: "running", finished_at: nil, heartbeat_at: 20.seconds.ago) + + expect(operation.state).to eq(:building) + end + + it "is interrupted when a later build was left behind (AC-1.6, decision 6)" do + loaded + build(index_file: gone, started_at: 2.hours.ago) + build(status: "running", finished_at: nil, heartbeat_at: (MTG::ArtBuild::STALE_AFTER + 1.minute).ago) + + expect(operation.state).to eq(:interrupted) + end + + it "is failed, worded as before, when the latest build failed (AC-1.6, decision 6)", :aggregate_failures do + loaded + build(index_file: gone, started_at: 2.hours.ago) + build(status: "failed", message: "Errno::ENOSPC: No space left on device") + + expect(operation).to have_attributes(state: :failed, summary: "Failed. An unexpected error stopped it.") + end + end end end diff --git a/spec/models/mtg/art_build_spec.rb b/spec/models/mtg/art_build_spec.rb index f01ed8b1..534294ab 100644 --- a/spec/models/mtg/art_build_spec.rb +++ b/spec/models/mtg/art_build_spec.rb @@ -242,4 +242,13 @@ def running(**attributes) = create(:mtg_art_build, status: "running", finished_a create(:mtg_art_build, status: "skipped", started_at: 1.hour.ago) expect(described_class.latest).to eq(finished) end + + describe "#index_file_present?" do + it "is true only while the file the build names is in the index directory (spec 039)", :aggregate_failures, :art_matching do + expect(build(:mtg_art_build, index_file: art_index_name)).to be_index_file_present + expect(build(:mtg_art_build, index_file: "art-index-default-cards-1-0123456789abcdef-9.bin.gz")).not_to be_index_file_present + expect(build(:mtg_art_build, index_file: nil)).not_to be_index_file_present + expect(build(:mtg_art_build, index_file: "../x")).not_to be_index_file_present + end + end end diff --git a/spec/models/mtg/art_spec.rb b/spec/models/mtg/art_spec.rb index c7d62d77..c79f8ad5 100644 --- a/spec/models/mtg/art_spec.rb +++ b/spec/models/mtg/art_spec.rb @@ -115,8 +115,23 @@ def refresh_run(status) = build(:catalog_refresh_run, collectible_type: "mtg", s end it "reports a finished build's counts and index", :art_matching do + name = art_index_name + create(:mtg_art_build, indexed_count: 9, without_image_count: 1, failed_count: 2, index_file: name) + expect(described_class.status_line).to eq("Art matching: ready; 9 artworks indexed, 1 without an image, 2 failed images; index #{name}") + end + + it "says the index file is missing, and what to press (spec 039 AC-2.1)", :aggregate_failures, :art_matching do create(:mtg_art_build, indexed_count: 9, without_image_count: 1, failed_count: 2, index_file: "art-index-v1-0123456789abcdef-9.bin.gz") - expect(described_class.status_line).to include("ready", "9 artworks indexed", "1 without an image", "2 failed images", "art-index-v1-0123456789abcdef-9.bin.gz") + line = described_class.status_line + expect(line).to start_with("Art matching: index file missing (art-index-v1-0123456789abcdef-9.bin.gz); press Build art index " \ + "on the admin catalog page to write it again.") + expect(line).to include("9 artworks indexed", "1 without an image", "2 failed images") + expect(line).not_to include("ready") + end + + it "says no file name was recorded for a finished build without one (spec 039 AC-2.1)", :art_matching do + create(:mtg_art_build, index_file: nil) + expect(described_class.status_line).to start_with("Art matching: index file missing (no file name recorded); ") end it "reports a failed build with its message and the index still in use", :art_matching do diff --git a/spec/requests/admin/catalogs_spec.rb b/spec/requests/admin/catalogs_spec.rb index 903029a8..6c18188c 100644 --- a/spec/requests/admin/catalogs_spec.rb +++ b/spec/requests/admin/catalogs_spec.rb @@ -500,7 +500,8 @@ def running(**attributes) = create(:catalog_refresh_run, :running, **attributes) it "shows a finished build's counts (AC-4.7) and a failed one's message and index (AC-4.8)", :aggregate_failures, :art_matching do create(:catalog_refresh_run) - build = create(:mtg_art_build, indexed_count: 31_904, without_image_count: 12, failed_count: 3, finished_at: Time.utc(2026, 10, 9, 5, 0)) + name = art_index_name + build = create(:mtg_art_build, index_file: name, indexed_count: 31_904, without_image_count: 12, failed_count: 3, finished_at: Time.utc(2026, 10, 9, 5, 0)) get admin_catalog_path expect(text(section("art_index"))).to eq("Art index Build art index Ready. Finished 9 Oct 2026 05:00 UTC " \ "Artworks indexed 31,904 Without an image 12 Failed images 3") @@ -508,7 +509,32 @@ def running(**attributes) = create(:catalog_refresh_run, :running, **attributes) build.update!(status: "failed", message: "Errno::ENOSPC: No space left on device") get admin_catalog_path expect(text(section("art_index"))).to eq("Art index Build art index Failed. An unexpected error stopped it. " \ - "Finished 9 Oct 2026 05:00 UTC Index in use none Technical detail Errno::ENOSPC: No space left on device") + "Finished 9 Oct 2026 05:00 UTC Index in use #{name} Technical detail Errno::ENOSPC: No space left on device") + end + + it "says a finished build's index file is missing, with its facts and an enabled button, and doesn't poll (spec 039 AC-1.8)", :aggregate_failures, :art_matching do + create(:catalog_refresh_run) + create(:mtg_art_build, index_file: "art-index-default-cards-1-0123456789abcdef-31904.bin.gz", indexed_count: 31_904, + without_image_count: 12, failed_count: 3, finished_at: Time.utc(2026, 10, 9, 5, 0)) + + get admin_catalog_path + + expect(text(section("art_index"))).to eq("Art index Build art index Index file missing. Build the art index again. " \ + "Finished 9 Oct 2026 05:00 UTC Index file art-index-default-cards-1-0123456789abcdef-31904.bin.gz " \ + "Artworks indexed 31,904 Without an image 12 Failed images 3") + expect(section("art_index").at_css("button")["disabled"]).to be_nil + expect(page.at_css("[data-controller~='poll']")).to be_nil + end + + it "queues one build when Build art index is pressed with the index file missing (spec 039 AC-3.1)", :aggregate_failures, :art_matching do + create(:catalog_refresh_run) + create(:mtg_art_build, index_file: "art-index-default-cards-1-0123456789abcdef-9.bin.gz") + get admin_catalog_path + expect(section("art_index").at_css("button")["disabled"]).to be_nil + + start("art_index") + expect(flash[:notice]).to eq("Art index build queued.") + expect(SolidQueue::Job.where(class_name: "MTG::Art::BuildJob").count).to eq(1) end end diff --git a/spec/support/art_helpers.rb b/spec/support/art_helpers.rb index 255bbf33..0d205095 100644 --- a/spec/support/art_helpers.rb +++ b/spec/support/art_helpers.rb @@ -11,3 +11,11 @@ FileUtils.rm_rf(MTG::Art.root) end end + +# A finished build's index file: writes an index for the version and returns its name, to set as the build's index_file +# (spec 039). A bare finished build row has none, which now reads as "index file missing". +module ArtIndexHelpers + def art_index_name(version = "default-cards-1") = MTG::Art::Index.write!(version, []).basename.to_s +end + +RSpec.configure { |config| config.include ArtIndexHelpers }