A version header on every WarpEngine response, and a movable pipeline link
ci/woodpecker/push/woodpecker Pipeline was successful

Every WarpEngine API response now carries `WarpEngine-Version`, so a client can branch on
the engine's age without a round trip to ask. Set in a before_action rather than after:
`rescue_from` never reaches an after_action, and a client needs the version most when
something came back wrong. The name lives in `WarpEngine::VERSION_HEADER`. The host's own
endpoints — the store registry — do not carry it, because they are not the engine.

A software has one pipeline, and the newest assignment now wins. Two pipelines pointing at
the same software was not an error the database caught; it was a link that silently did
nothing, with the software still showing whichever row came first. Assigning a software
another pipeline holds therefore moves it, the admin says which pipeline it was taken
from, and `Pipeline#software_taken_from` carries that for anything else that cares.
Deliberately a callback and not a unique index: rows here are soft-deleted, and a unique
index counts deleted rows, so a pipeline removed last year would block its software from
ever being linked again.

The engine is 0.4.0. The site's /stores page and its screenshot follow the client's new
name, and the shot is a fresh one showing the greyed-out titles the client now lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:46:29 +02:00
co-authored by Claude Opus 5
parent bf10aa87e8
commit 3d5ba3f31d
12 changed files with 160 additions and 9 deletions
+3
View File
@@ -49,6 +49,9 @@ repository's `config.json` and points it at `catalog_url`, and that file remains
authority on how the store behaves; a repository without a config file is treated
as no repository at all.
Every WarpEngine API response also carries a `WarpEngine-Version` header — the registry
above is the host's own endpoint and does not, because it is not part of the engine.
This lives in the host app **on purpose, not in WarpEngine**. The engine serves
one catalog and has no business knowing which stores exist for it; who ships a
store for a catalog is a property of the site.
+1 -1
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.3.0)
warp_engine (0.4.0)
apipie-rails
blueprinter
rails (>= 8.0)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

After

Width:  |  Height:  |  Size: 365 KiB

+3 -3
View File
@@ -199,13 +199,13 @@ export default {
subtitle: 'Which engines build for which platforms.',
},
stores: {
title: 'WarpEngine Store',
title: 'WarpEngine Client',
subtitle: 'Our games on your own machine, kept up to date — as an app, a command line, or your console\'s own library.',
lead: 'Our whole catalog on your own machine, kept up to date. The quickest way in is the app: install it, and games land in your application menu like anything else you have installed. If a window is the wrong shape — a server, a script, or a console with its own library — there is a command line for desktops, and integrations that put our games into Batocera\'s and RetroArch\'s own libraries. All of it downloads straight from this site with box art, and all of it uses nothing but the Python 3 standard library.',
clientTitle: 'Get WarpEngine Store',
clientTitle: 'Get WarpEngine Client',
clientDesc: 'A small desktop application: the catalog as a grid of cards, one click to install a game into your own application menu, one to play it, one to remove it. Nothing to set up first — it downloads the store itself on first run — and on Windows it is the way in, because `curl … | sh` does not exist there.',
clientPlatforms: 'Linux, macOS and Windows',
clientShotAlt: 'The WarpEngine Store window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
clientPointSetup: 'Sets the store up itself on first run — nothing to install beforehand.',
clientPointBrowse: 'Filter by platform, by what is installed, or by what has an update.',
clientPointSame: 'The same store underneath, so the command line keeps working on the same games.',
+3 -3
View File
@@ -199,13 +199,13 @@ export default {
subtitle: 'Melyik engine melyik platformra fordít.',
},
stores: {
title: 'WarpEngine Store',
title: 'WarpEngine Client',
subtitle: 'A játékaink a saját gépeden, mindig frissen — alkalmazásként, parancssorból vagy a konzolod saját könyvtárában.',
lead: 'A teljes katalógusunk a saját gépeden, mindig frissen. A legrövidebb út az alkalmazás: telepítsd, és a játékok ugyanúgy megjelennek az alkalmazásmenüdben, mint bármi más. Ahol az ablak a rossz forma — szerveren, szkriptből, vagy egy konzolon, aminek saját könyvtára van —, ott van parancssor asztali gépekre, és integráció, ami a Batocera, illetve a RetroArch saját könyvtárába teszi a játékainkat. Mindegyik közvetlenül erről az oldalról töltődik le, borítóképpel, és mindegyikhez elég a Python 3 alapkészlete.',
clientTitle: 'Töltsd le a WarpEngine Store-t',
clientTitle: 'Töltsd le a WarpEngine Clientet',
clientDesc: 'Egy kis asztali alkalmazás: a katalógus kártyákban, egy kattintás a telepítés a saját alkalmazásmenüdbe, egy az indítás, egy az eltávolítás. Előre semmit nem kell beállítani — első indításkor magát a store-t is letölti —, Windowson pedig ez az út, mert ott a `curl … | sh` nem létezik.',
clientPlatforms: 'Linux, macOS és Windows',
clientShotAlt: 'A WarpEngine Store ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
clientPointSetup: 'Első indításkor magát a store-t is beállítja — előre semmit nem kell telepíteni.',
clientPointBrowse: 'Szűrhetsz platformra, a telepítettekre vagy arra, amihez frissítés van.',
clientPointSame: 'Alatta ugyanaz a store, tehát a parancssor ugyanazokon a játékokon dolgozik tovább.',
+23
View File
@@ -399,6 +399,20 @@ Hosts that must support older engine versions can feature-detect with
| `GET /api/download?path=` | Serves an artifact and logs a download record |
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
### `WarpEngine-Version`
Every response above carries the engine's version in a `WarpEngine-Version` header, so a
client can branch on the engine's age without a round trip to ask:
```
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
WarpEngine-Version: 0.4.0
```
Set before the action runs rather than after, which means an error response carries it
too — a client needs the version most when something came back wrong. The name is
`WarpEngine::VERSION_HEADER`, so nothing spells it out twice.
## Admin integration
The host owns the single ActiveAdmin instance — authentication (Devise),
@@ -419,6 +433,15 @@ host:
former Go backend (Go zero-time timestamps, camelCase keys, legacy flat
path fields).
- Model extension points: `ActiveSupport.on_load(:warp_engine_<model>)` hooks.
- **A software has one pipeline, and the newest assignment wins.** `Software#pipeline` is
a `has_one`, so two pipelines pointing at the same software is not an error the database
catches — it is a link that silently does nothing, with the software still showing
whichever row came first. Assigning a software that another pipeline holds therefore
*moves* it: the previous holder is left without one, the admin says which one it took it
from, and `Pipeline#software_taken_from` carries that list for anything else that cares.
Deliberately a callback rather than a unique index: rows here are soft-deleted, and a
unique index counts deleted rows, so a pipeline removed last year would block its
software from ever being linked again.
## Tests
+16 -1
View File
@@ -46,11 +46,26 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
f.input :software_id, as: :select,
collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] },
include_blank: "- none -"
include_blank: "- none -",
hint: "One pipeline per software. Picking one that another pipeline already " \
"has moves the link here rather than refusing it."
end
f.actions
end
controller do
# The reassignment itself is the model's job; this only makes it visible. Without a
# word about it, the other pipeline loses its software with nothing on screen to say
# that it happened.
def update
super
taken = resource.software_taken_from
return if taken.blank?
flash[:notice] = [ flash[:notice], "Software taken from #{taken.join(', ')}." ].compact.join(" ")
end
end
sidebar "Details", only: :show do
attributes_table_for resource do
row :id
@@ -5,6 +5,12 @@ module WarpEngine
formats [ "json" ]
end
# Every response the engine serves names the version that served it, so a client can
# branch on the engine's age without a round trip to ask. Set *before* the action,
# not after: an error handled by `rescue_from` never reaches an after_action, and a
# client needs the version most when something came back wrong.
before_action :set_version_header
rescue_from StandardError do |e|
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
render json: { error: "Internal server error" }, status: :internal_server_error
@@ -24,6 +30,10 @@ module WarpEngine
private
def set_version_header
response.headers[WarpEngine::VERSION_HEADER] = WarpEngine::VERSION
end
def resolve_mime(path)
ext = File.extname(path.to_s).delete_prefix(".")
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
@@ -8,8 +8,25 @@ module WarpEngine
belongs_to :software, class_name: "WarpEngine::Software", optional: true
# Pipelines this record took the software from during the last save, by full name.
# The admin says so out loud: a silent reassignment is what made the old behaviour
# confusing in the first place.
attr_reader :software_taken_from
default_scope { where(deleted_at: nil) }
# One pipeline per software, and the newest assignment wins.
#
# `Software#pipeline` is a `has_one`, so two pipelines pointing at the same software
# is not an error — it is worse than one: the software keeps showing whichever row
# comes first, and assigning it elsewhere looks like it did nothing. Rather than
# refusing the assignment, the link moves: whoever held that software lets go of it.
#
# Deliberately a callback and not a unique index. Rows here are soft-deleted, and a
# unique index counts deleted rows too, so a pipeline someone removed last year would
# block the software from ever being linked again.
before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id?
validates :woodpecker_repo_id, presence: true, uniqueness: true
validates :repo_owner, presence: true
validates :repo_name, presence: true
@@ -31,6 +48,16 @@ module WarpEngine
%w[software]
end
private
def claim_software_from_other_pipelines
return if software_id.blank?
others = Pipeline.where(software_id: software_id).where.not(id: id)
@software_taken_from = others.map(&:full_name)
others.update_all(software_id: nil, updated_at: Time.current)
end
ActiveSupport.run_load_hooks(:warp_engine_pipeline, self)
end
end
@@ -1,3 +1,8 @@
module WarpEngine
VERSION = "0.3.0"
VERSION = "0.4.0"
# The header every API response carries. Named here rather than written out at the one
# place that sets it: clients read it, the README documents it, and a string in three
# places is a string that eventually differs in one of them.
VERSION_HEADER = "WarpEngine-Version".freeze
end
@@ -59,4 +59,42 @@ RSpec.describe WarpEngine::Pipeline do
describe "associations" do
it { is_expected.to belong_to(:software).optional }
end
# A software has one pipeline. Two pipelines pointing at the same one is not an error
# the database catches, it is a link that silently does nothing — so the newest
# assignment takes it, and says what it took it from.
describe "assigning a software another pipeline already has" do
it "moves the link and leaves the other pipeline without one" do
software = create(:software)
held_by = create(:pipeline, software: software, repo_owner: "games", repo_name: "old")
taking = create(:pipeline, repo_owner: "games", repo_name: "new")
taking.update!(software: software)
expect(taking.reload.software).to eq(software)
expect(held_by.reload.software).to be_nil
expect(software.reload.pipeline).to eq(taking)
end
it "names the pipelines it took the software from" do
software = create(:software)
create(:pipeline, software: software, repo_owner: "games", repo_name: "old")
taking = create(:pipeline, repo_owner: "games", repo_name: "new")
taking.update!(software: software)
expect(taking.software_taken_from).to eq([ "games/old" ])
end
it "leaves other pipelines alone when the software is cleared" do
software = create(:software)
keeps = create(:pipeline, software: software, repo_owner: "games", repo_name: "keeps")
other = create(:pipeline, repo_owner: "games", repo_name: "other")
other.update!(software: nil)
expect(keeps.reload.software).to eq(software)
expect(other.software_taken_from).to be_nil
end
end
end
@@ -0,0 +1,30 @@
require "rails_helper"
# Every response the engine serves carries the version that served it, so a client can
# branch on the engine's age without asking a separate endpoint for it.
RSpec.describe "the WarpEngine-Version header", type: :request do
it "is on a normal response" do
create(:software)
get "/api/software"
expect(response).to have_http_status(:ok)
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
# The one a client needs most: something came back wrong, and it wants to know whether
# the engine on the other end is old enough to explain it. `rescue_from` never reaches
# an after_action, which is why the header is set before the action runs.
it "is on an error response" do
get "/api/image/999999"
expect(response).to have_http_status(:not_found)
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
it "is on a served file" do
get "/file/nothing-here.zip"
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
end