A version header on every WarpEngine response, and a movable pipeline link
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@@ -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,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 |
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user