Add a typed-overlay model with workshop as the first non-external type: deduplicated WorkshopItem registry, symlink-based overlay directories, auto-rebuild after item changes, admin global refresh, and a unified Create-overlay UI with web-managed paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
L4D2 Workshop Overlays Implementation Plan
Approval gate: This plan may be written and refined without further approval. Do not implement code changes from this plan until the user explicitly approves implementation.
Goal: Implement the workshop overlay feature per docs/superpowers/specs/2026-05-07-l4d2-workshop-overlays-design.md. Add a WorkshopItem registry, a typed Overlay.type column with a builder registry, a workshop builder that downloads from the Steam Web API and manages symlinks into a deduplicated cache, and the supporting routes, templates, jobs, and tests.
Architecture: Keep the v1 single-process Flask architecture. New code is additive: a WorkshopBuilder class registered in a builder dispatcher, a steam_workshop service module for the Steam Web API and downloader, two new database tables and one extended one, and two new job operations on the existing in-process worker. fuse-overlayfs mount handling in l4d2host is unchanged — workshop content arrives at overlay paths the same way externals do today.
Locked Decisions
See docs/superpowers/specs/2026-05-07-l4d2-workshop-overlays-design.md for the design rationale. Implementation-relevant decisions:
- Typed overlays:
external(existing rows; no-op builder) andworkshop(new); future types deferred. - No JSON
source_configblob; per-type structured data in proper tables. WorkshopItemis a global deduplicated registry keyed onsteam_id. Cache at/var/lib/left4me/workshop_cache/{steam_id}.vpk.- Overlay symlinks are absolute, named
{steam_id}.vpk; no Steam filename in any on-disk path. overlay_workshop_itemsis a pure association; toggle = remove/re-add.- Collections are atomic UI bulk-imports; DB never tracks collection attribution.
- Single global admin "Refresh all workshop items" button.
- No cache GC in v1.
Overlay.user_idis the scope (NULL = system, set = private); independent oftype.- Workshop overlays default to private; existing externals stay system-wide.
- One unified Create-overlay button with type radio; no path field — paths are always
str(overlay_id). consumer_app_id == 550validated at fetch/add; not stored.- Input field accepts numeric ID, full Workshop URL, or multi-line batch.
- Auto-rebuild after add/remove with build coalescing.
- HTTPS for all Steam Web API calls.
Overlay.idusesAUTOINCREMENT;create_overlay_directoryusesexist_ok=False.- Two partial unique indexes for overlay names:
(name) WHERE user_id IS NULLand(name, user_id) WHERE user_id IS NOT NULL.
Current Gap
Overlayrows haveid,name,path, no type, no scope.- The web app cannot download anything from Steam; users must SFTP
.vpkfiles into prepared overlay directories. - The job worker has no operations for overlay builds or workshop refreshes.
- The mount/build pipeline assumes overlay directories are externally populated.
- There is no UI affordance to add or list workshop content.
Task 1: Extend Tests First — Schema Migration And Models
Files:
- Create:
l4d2web/tests/test_workshop_overlay_models.py - Modify:
l4d2web/tests/test_models.py(extend) — partial unique index behavior
Write tests against fresh SQLite schemas asserting:
- An
Overlaymigration round-trip: existing rows acquiretype='external'anduser_id=NULL; theirnamevalues remain unique by partial index. - After migration, two externals (both
user_id=NULL) with the same name are rejected by the system partial unique index. - After migration, two users may both own a workshop overlay named
"my-maps"(per-user partial unique index). WorkshopItem.steam_idis unique; concurrent inserts of the samesteam_idraise integrity errors.overlay_workshop_itemsenforcesUNIQUE(overlay_id, workshop_item_id).Overlaydeletion cascadesoverlay_workshop_itemsrows but does not deleteWorkshopItemrows (ON DELETE RESTRICT).Job.overlay_idis nullable and referencesoverlays(id).Overlay.iddoes not reuse a deleted ID after the migration (AUTOINCREMENT).
Verification command:
pytest l4d2web/tests/test_workshop_overlay_models.py l4d2web/tests/test_models.py -q
Expected before implementation: FAIL.
Task 2: Schema Migration And ORM Mappings
Files:
- Create:
l4d2web/alembic/versions/0002_workshop_overlays.py - Modify:
l4d2web/models.py
Migration 0002_workshop_overlays (down_revision = "b2c684fddbd3"):
op.batch_alter_table("overlays"):- Add
type VARCHAR(16) NOT NULL DEFAULT 'external'(server_default during migration; remove after backfill). - Add
user_id INTEGER NULL REFERENCES users(id). - Drop the existing
unique=Trueonname. - Add index
ix_overlays_type_user_idon(type, user_id). - Switch
idtoAUTOINCREMENT.
- Add
- After batch alter, create the two partial unique indexes via raw
op.create_index(..., postgresql_where=..., sqlite_where=...):uq_overlay_name_systemon(name)WHERE user_id IS NULL.uq_overlay_name_per_useron(name, user_id)WHERE user_id IS NOT NULL.
op.create_table("workshop_items", ...)per spec data-model section.op.create_table("overlay_workshop_items", ...)with the unique constraint and the reverse-lookup index.op.batch_alter_table("jobs"): addoverlay_id INTEGER NULL REFERENCES overlays(id).
ORM (models.py):
- Extend
Overlay: addtype,user_id. Dropunique=Trueonname. Set__table_args__with the two partial indexes andix_overlays_type_user_id. - Extend
Job: addoverlay_idmapped column with FK. - New
WorkshopItemandOverlayWorkshopItemclasses per spec. Set upOverlay.workshop_itemsrelationship through the association.
Verification command:
pytest l4d2web/tests/test_workshop_overlay_models.py l4d2web/tests/test_models.py -q
Expected after implementation: PASS.
Run alembic against a fresh test DB to verify upgrade and downgrade succeed.
Task 3: Tests First — Steam Web API And Downloader
Files:
- Create:
l4d2web/tests/test_steam_workshop.py
Mock HTTP with responses or pytest-httpserver. Cover:
parse_workshop_inputaccepts a single numeric ID, a single Workshop URL (steamcommunity.com/sharedfiles/filedetails/?id=N), and a multi-line whitespace-separated batch of either; returns deduplicated ordered list of digit-only IDs.parse_workshop_inputrejects garbage, paths outside?id=, non-digit IDs.resolve_collectionPOSTs to the HTTPS endpoint with the form-encoded payload and returnspublishedfileidchildren.fetch_metadata_batchPOSTs once withitemcount=N; returns parsedWorkshopMetadataper item; capturesresult != 1intolast_error; raisesWorkshopValidationErrorwhen anyconsumer_app_id != 550during user-add; logs and skips during refresh-mode.WorkshopMetadata.preview_urlis captured.download_to_cachewritescache_root/{steam_id}.vpk.partial, thenos.replaceto the final name; setsos.utime(file, (time_updated, time_updated)).download_to_cacheis idempotent: a second call where on-disk(mtime, size)matches(time_updated, file_size)is a no-op (no HTTP request issued).refresh_allruns downloads viaThreadPoolExecutor(max_workers=8)and reports per-item errors without aborting the batch.- All Steam API URLs use
https://.
Verification command:
pytest l4d2web/tests/test_steam_workshop.py -q
Expected before implementation: FAIL.
Task 4: Steam Workshop Service Module
Files:
- Create:
l4d2web/services/steam_workshop.py
Public surface:
def parse_workshop_input(raw: str) -> list[str]: ...
def resolve_collection(collection_id: str) -> list[str]: ...
def fetch_metadata_batch(steam_ids: list[str], *, mode: Literal["add","refresh"]) -> list[WorkshopMetadata]: ...
def download_to_cache(meta: WorkshopMetadata, cache_root: Path, *, on_progress=None, should_cancel=None) -> Path: ...
def refresh_all(items: list[WorkshopItem], cache_root: Path, executor_workers: int = 8) -> RefreshReport: ...
Implementation rules:
- Endpoints are HTTPS:
https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/
- Form-encoded POSTs with
itemcount=N/collectioncount=Nandpublishedfileids[i]=…per index. - Per-request timeout 30s; per-item ceiling 5min. No retry or backoff in v1.
consumer_app_id != 550:- In
mode="add": raiseWorkshopValidationErrorwith the offendingsteam_id. - In
mode="refresh": log and skip; do not abort other items.
- In
result != 1: capture Steam's result code in the item'slast_error; do not download; do not abort siblings.- Cooperative cancellation:
download_to_cachechecksshould_cancel()between chunked reads;refresh_all's executor checks before each task. WorkshopMetadatais a dataclass withsteam_id, title, filename, file_url, file_size, time_updated, preview_url, consumer_app_id, result.RefreshReportaggregates per-item outcomes for the caller's job log.- Use a single
requests.Sessionper call site for connection reuse.
Verification command:
pytest l4d2web/tests/test_steam_workshop.py -q
Expected after implementation: PASS.
Task 5: Tests First — Path Helpers And Overlay Creation
Files:
- Create:
l4d2web/tests/test_workshop_paths.py - Create:
l4d2web/tests/test_overlay_creation.py
Cover:
workshop_cache_root()returnsLEFT4ME_ROOT/workshop_cache.cache_path(steam_id)returnscache_root / f"{steam_id}.vpk"for valid digit strings; rejects non-digits, slashes, dot-dot.generate_overlay_path(overlay_id)returnsstr(overlay_id); passesvalidate_overlay_reffroml4d2host.paths.create_overlay_directory(overlay)createsLEFT4ME_ROOT/overlays/{path}/withexist_ok=False. Calling twice raises (DB/disk drift surfaced loudly).
Verification command:
pytest l4d2web/tests/test_workshop_paths.py l4d2web/tests/test_overlay_creation.py -q
Expected before implementation: FAIL.
Task 6: Path Helpers And Overlay Creation
Files:
- Create:
l4d2web/services/workshop_paths.py - Create:
l4d2web/services/overlay_creation.py
workshop_paths:
def workshop_cache_root() -> Path: ... # LEFT4ME_ROOT/workshop_cache
def cache_path(steam_id: str) -> Path: ... # validates digits-only; returns cache_root/{steam_id}.vpk
overlay_creation:
def generate_overlay_path(overlay_id: int) -> str: ... # str(overlay_id) + validate_overlay_ref
def create_overlay_directory(overlay: Overlay) -> None: # makedirs(..., exist_ok=False)
...
Verification command:
pytest l4d2web/tests/test_workshop_paths.py l4d2web/tests/test_overlay_creation.py -q
Expected after implementation: PASS.
Task 7: Tests First — Overlay Builders
Files:
- Create:
l4d2web/tests/test_overlay_builders.py
Cover with tmp_path:
BUILDERSdict resolves"external"and"workshop"to instances; unknown types raiseKeyError(caller's error).ExternalBuilder.build()is a no-op: makes the overlay directory if missing, writes one log line, returns. Existing files in the directory are untouched.WorkshopBuilder.build()against a fixture overlay with three associatedWorkshopItemrows (two with cache files present, one without):- Creates
left4dead2/addons/if missing. - Creates symlinks
addons/{steam_id_a}.vpk → cache_root/{steam_id_a}.vpkfor items with cache files. Symlinks are absolute. - Skips the uncached item; emits a warning log line. Does not create a dangling symlink.
- On a re-run with the same associations: no FS changes; logs report
unchanged=2 skipped(uncached)=1. - On a re-run after one association is removed: removes the obsolete symlink only; leaves cache files alone.
- On a re-run after one item is added: adds only the new symlink.
- Files in
addons/that aren't symlinks into the cache are left untouched.
- Creates
should_cancelmid-build: stops between filesystem ops; partial state is consistent and a re-run heals.
Verification command:
pytest l4d2web/tests/test_overlay_builders.py -q
Expected before implementation: FAIL.
Task 8: Overlay Builders And Dispatcher
Files:
- Create:
l4d2web/services/overlay_builders.py
class OverlayBuilder(Protocol):
def build(self, overlay: Overlay, *, on_stdout, on_stderr, should_cancel) -> None: ...
class ExternalBuilder: ...
class WorkshopBuilder: ...
BUILDERS: dict[str, OverlayBuilder] = {
"external": ExternalBuilder(),
"workshop": WorkshopBuilder(),
}
WorkshopBuilder.build():
- Load the overlay's
WorkshopItemrows. os.makedirs(overlay_root / "left4dead2/addons", exist_ok=True).- Compute
desired = {f"{steam_id}.vpk": cache_path(steam_id)}for items wherelast_downloaded_at IS NOT NULLand the cache file exists. Skip and warn for items missing a cache file. - Inspect existing entries in
addons/viaos.scandir: keep entries that are not symlinks intoworkshop_cache; otherwise diff againstdesiredand apply changes viaos.unlinkandos.symlink(absolute_target, link_path). - Emit
created N, removed M, unchanged K, skipped (uncached) Slog line. - Check
should_cancel()between filesystem ops.
Verification command:
pytest l4d2web/tests/test_overlay_builders.py -q
Expected after implementation: PASS.
Task 9: Tests First — Worker Scheduler Truth Table And Coalescing
Files:
- Modify:
l4d2web/tests/test_job_worker.py
Add coverage:
- Truth table for
can_start:installnot claimed whilerefresh_workshop_items, anybuild_overlay, or any server job is running.refresh_workshop_itemsnot claimed whileinstall, anybuild_overlay, or any server job is running.build_overlay(N)not claimed whileinstall,refresh_workshop_items, or anotherbuild_overlay(N)is running. Twobuild_overlayjobs for different overlay IDs claim concurrently.- Server start/init blocks if
refresh_workshop_itemsruns or if anybuild_overlay(N)runs where N ∈ overlays of the server's blueprint.
enqueue_build_overlay(overlay_id):- Inserts a new queued job when no pending job exists.
- Returns the existing pending job when one is already queued (coalescing).
- Does not coalesce against running jobs (a new add after build start gets a fresh queued job).
refresh_workshop_itemspost-completion enqueuesbuild_overlayonly for overlays whose items hadtime_updatedadvance orfilenamechange; each such enqueue uses the coalescing helper.
Verification command:
pytest l4d2web/tests/test_job_worker.py -q
Expected before implementation: FAIL.
Task 10: Worker Scheduler And New Operations
Files:
- Modify:
l4d2web/services/job_worker.py
Changes:
- Define
OVERLAY_OPERATIONS = {"build_overlay"}andGLOBAL_OPERATIONS = {"install", "refresh_workshop_items"}. Updatemalformed_server_jobto allowserver_id IS NULLfor these. - Extend
SchedulerStatewithrunning_overlays: set[int]andrefresh_running: bool. - Update
claim_next_job():- Compute
running_overlaysfrom queries againstrunningjobs of operationbuild_overlay. - Apply the truth-table rules above.
- Continue using
created_at, idordering for deterministic claim.
- Compute
- Add
enqueue_build_overlay(overlay_id: int) -> Jobhelper:- Look for
queuedbuild_overlayjob with sameoverlay_id. Return it if present. - Otherwise insert a new queued job with
overlay_idset,server_id=None,operation="build_overlay".
- Look for
- Update
run_jobdispatch:build_overlay→ loadOverlay, dispatch toBUILDERS[overlay.type].build(overlay, on_stdout, on_stderr, should_cancel).refresh_workshop_items→ callsteam_workshop.refresh_all(...). After completion, for each affected overlay, callenqueue_build_overlay(overlay_id).
Verification command:
pytest l4d2web/tests/test_job_worker.py -q
Expected after implementation: PASS.
Task 11: Tests First — Routes, Permissions, And Auto-Rebuild
Files:
- Modify:
l4d2web/tests/test_overlays.py - Create:
l4d2web/tests/test_workshop_routes.py
Cover:
POST /overlayswithtype='workshop'andnamesucceeds for any logged-in user;pathis auto-generated;user_idis set; the directory exists atLEFT4ME_ROOT/overlays/{id}.POST /overlayswithtype='external'succeeds only for admins;user_idis NULL.- Duplicate workshop name within the same user is rejected; duplicate names across users are accepted.
- Duplicate external name is rejected.
- Non-admins see
type='external' OR user_id=current_user.idonly when listing overlays. POST /overlays/{id}/itemswith one numeric ID adds an association and enqueues a coalescedbuild_overlay. The response is an HTMX fragment of the updated item table.POST /overlays/{id}/itemswith a multi-line batch (mix of IDs and URLs) adds all and enqueues one coalesced job for the batch.POST /overlays/{id}/itemswith a collection ID resolves members and adds N associations.- Adding a non-L4D2 item (
consumer_app_id != 550) returns HTTP 400 with a useful message; no association is created. - Adding an item already in the overlay returns "already in overlay" (no 500).
POST /overlays/{id}/items/{item_id}/deleteremoves the association and enqueues a coalesced build.POST /overlays/{id}/buildenqueues the manual rebuild and redirects to the job page.POST /admin/workshop/refreshis admin-only; non-admins receive 403.
Mock steam_workshop HTTP layer for these tests.
Verification command:
pytest l4d2web/tests/test_overlays.py l4d2web/tests/test_workshop_routes.py -q
Expected before implementation: FAIL.
Task 12: Routes And Templates
Files:
- Modify:
l4d2web/routes/overlay_routes.py - Create:
l4d2web/routes/workshop_routes.py - Modify:
l4d2web/routes/page_routes.py - Modify:
l4d2web/templates/overlays.html - Modify:
l4d2web/templates/overlay_detail.html - Create:
l4d2web/templates/_overlay_item_table.html - Modify:
l4d2web/templates/admin.html - Modify:
l4d2web/app.py(register the workshop blueprint)
overlay_routes.py:
create_overlay: readtypeandnamefrom form. Nopathfield accepted.type='external': admin-only;user_id=NULL. After insert, setpath = generate_overlay_path(id); callcreate_overlay_directory(overlay).type='workshop': any logged-in user;user_id=current_user.id. After insert, setpath = generate_overlay_path(id); callcreate_overlay_directory(overlay).
update_overlay: forbid changingtypeandpath. Workshop: owner or admin can editname. External: admin-onlynameedits.delete_overlay: after the row deletes,shutil.rmtree(LEFT4ME_ROOT/overlays/{path})only ifoverlay.path == str(overlay.id)(legacy externals are left alone). Cache untouched.
workshop_routes.py:
POST /overlays/{id}/items: parse input viaparse_workshop_input; if a collection ID, resolve members; batch-fetch metadata inmode="add"; reject non-550 with HTTP 400; upsertWorkshopItemvia SQLiteINSERT ... ON CONFLICT DO UPDATEonsteam_id; bulk-add associations catching(overlay_id, workshop_item_id)unique violations; callenqueue_build_overlay(overlay_id); return rendered_overlay_item_table.htmlfragment.POST /overlays/{id}/items/{item_id}/delete: ownership check; remove association; callenqueue_build_overlay(overlay_id); return updated fragment.POST /overlays/{id}/build: ownership check; enqueue (coalesced); redirect to/jobs/{job_id}.POST /admin/workshop/refresh:@require_admin; insert arefresh_workshop_itemsqueued job; redirect to/admin/jobs.
page_routes.py:
overlays(): admins see all; non-admins seetype='external' OR user_id=current_user.id.overlay_detail(): loadWorkshopItemrows for workshop-type overlays.
Templates:
overlays.html: add Type column. Modal has type radio (External | Workshop) and name field. No path field.overlay_detail.html: branch onoverlay.type.- External view: read-only path display, name edit (admin only).
- Workshop view: an
<textarea>accepting one or many IDs/URLs plus a radio (Items | Collection); item table with thumbnail (preview_url),steam_idlinked to Steam, title, filename, time_updated, file_size, last_error, Remove; Rebuild button; small status indicator showing the latest related job.
_overlay_item_table.html: renderable standalone for HTMX swaps.admin.html: add a CSRF-protected "Refresh all workshop items" button.
Verification command:
pytest l4d2web/tests/test_overlays.py l4d2web/tests/test_workshop_routes.py -q
Expected after implementation: PASS.
Task 13: Tests First — Initialize-Time Guard
Files:
- Modify:
l4d2web/tests/test_l4d2_facade.py(or create if missing)
Cover:
initialize_server(server_id)callsBUILDERS[overlay.type].build()for each overlay in the blueprint before writing the spec.- For workshop overlays, when an associated
WorkshopItemlacks a cache file (workshop_cache/{steam_id}.vpkmissing),initialize_serverraises a clear error containing the missingsteam_ids and the overlay name; the spec is not written;l4d2ctl initializeis not invoked. - For workshop overlays where all items have cache files, the symlinks are present and
l4d2ctl initializeruns.
Verification command:
pytest l4d2web/tests/test_l4d2_facade.py -q
Expected before implementation: FAIL.
Task 14: Initialize-Time Guard
Files:
- Modify:
l4d2web/services/l4d2_facade.py
Implementation:
- Before writing the temp spec, iterate over the blueprint's overlays and call
BUILDERS[overlay.type].build(...). - For workshop overlays, the builder logs and skips uncached items rather than failing. After all builders run, perform a second pass: query the blueprint's workshop overlays for any associated
WorkshopItemwith no cache file. If any are found, raise an exception whose message names the missingsteam_ids and points at the overlay page (Open overlay {name} ({id}) and click Build).
Verification command:
pytest l4d2web/tests/test_l4d2_facade.py -q
Expected after implementation: PASS.
Task 15: Deploy Provisioning
Files:
- Modify:
deploy/install.sh(or whichever provisioning script creates/var/lib/left4me/) - Modify:
deploy/README.md
Behavior:
- Provisioning creates
/var/lib/left4me/workshop_cache/(mode 0755), owned by the web user. deploy/README.mddocuments:- The new directory and its purpose.
- Permission requirement: web user owns; host user reads (shared group with
g+rif uids differ). LEFT4ME_ROOTlayout updated with the new subtree.
No tests; verify via test deploy.
Task 16: Full Verification And Manual Test Plan
Run focused suites first:
pytest l4d2web/tests/test_workshop_overlay_models.py -q
pytest l4d2web/tests/test_models.py -q
pytest l4d2web/tests/test_steam_workshop.py -q
pytest l4d2web/tests/test_workshop_paths.py l4d2web/tests/test_overlay_creation.py -q
pytest l4d2web/tests/test_overlay_builders.py -q
pytest l4d2web/tests/test_job_worker.py -q
pytest l4d2web/tests/test_overlays.py l4d2web/tests/test_workshop_routes.py -q
pytest l4d2web/tests/test_l4d2_facade.py -q
Then run the full web suite:
pytest l4d2web/tests -q
Manual test plan on the test deploy:
- Apply migration on a copy of the prod DB; verify all existing overlays read as
type='external',user_id=NULL; names still unique by partial index; two externals with the same name are rejected. - As non-admin, create a workshop overlay. Add a known popular L4D2 addon by URL. Verify the build job auto-enqueues. Verify symlink + cache file. Confirm web UI shows metadata and thumbnail.
- Paste a multi-line block of item IDs and URLs. Verify all are parsed and added; verify coalescing (only one
build_overlayjob runs). - Add a 50-item collection. Verify all 50 metadata rows appear and no UI mention of "from collection". Verify single coalesced build job.
- Remove an item. Verify auto-rebuild removes the symlink while the cache file remains.
- As admin, click Refresh All. Verify only items with newer
time_updatedre-download. Verify affected overlays get coalescedbuild_overlayjobs enqueued. - Boot an L4D2 server with a workshop overlay attached. Connect locally and confirm the maps appear in the map vote and load.
- Concurrency probe: enqueue Refresh All while a
build_overlayis queued; verify scheduler waits per truth table. - Initialize-time guard: manually delete a cache file for an item that's in an overlay attached to a server's blueprint. Try to start the server; verify clear error mentioning the missing
steam_id. - Negative: paste a non-L4D2 workshop ID (e.g., a Skyrim mod). Expect HTTP 400 with a clear message; no row inserted.
- Negative: simulate Steam API down (block egress). Verify add fails with clean error, not 500. Verify refresh job logs the failure.
Commit Strategy
Use small commits after passing relevant tests:
feat(l4d2-web): typed overlays + workshop schema migrationfeat(l4d2-web): steam workshop API client and downloaderfeat(l4d2-web): overlay path helpers and creationfeat(l4d2-web): overlay builder registry with workshop builderfeat(l4d2-web): worker support for build_overlay and refresh_workshop_itemsfeat(l4d2-web): workshop overlay UI (routes + templates)feat(l4d2-web): initialize-time guard for uncached workshop itemsfeat(deploy): workshop_cache provisioning
Do not commit unless the user explicitly asks for commits.
Open Approval Gate
Before modifying implementation files, ask the user for explicit approval to proceed with the workshop-overlays implementation.