Restructures the disk-cleanup command to show location, current on-disk
size, and an estimated reclaim per area (attachments, project files,
container workspaces) before touching anything - --dry-run stops there.
A real run re-executes each check, reports actual items/bytes freed,
an "After" size per area, and a final total with elapsed time, flagging
any drift between the estimate and execution passes (e.g. the live
server wrote something in between).
store.gc_workspaces() now reports bytes freed alongside the removed
count (previously count-only), so the containers gc-workspaces CLI
action picks up the same reporting for free.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5tFWkm3UbcstcbPKFE5gP
Happy 404: an HTML 404 (unmatched route, or an explicit not-found inside a
real route) now renders a random existing post instead of the error page,
using the exact same context builder as a real post view. Toggle is the
happy_404_enabled site setting (default on, /admin/settings); JSON/API
requests and a handful of excluded prefixes are never affected. The pool of
candidate slugs is cached in-process and resampled periodically so it stays
fast and eventually cycles the whole posts table; on any internal failure it
falls straight through to the real 404 page.
Applying this everywhere surfaced ~60 existing tests that asserted a literal
404 for a legitimate resource-not-found flow (deleted post, unknown
container, wrong project slug, etc.) - each now disables the setting for the
duration of that specific check and restores it after, so the underlying
not-found behavior stays covered independently of the new feature.
Post page also gained, all built on the same shared post_page_context() so
they render identically on both a real post and a happy-404 page:
- A left sidebar (three separate cards, matching /feed's sidebar-card
convention) for "Gists from {author}", "Projects from {author}" (private
projects filtered through the normal visibility check), and "Related
Discussions" - each cached per author and invalidated on create/edit/
delete so new content shows up immediately.
- A right column reusing /feed's exact Daily Topic widget class for up to
three "Featured" articles (the existing but previously-unused `featured`
news flag), cached as a pool with per-request random sampling.
- A "Next post -> " link beside "Back to Feed", pointing at the next older
post site-wide (blocked authors skipped). Wired through the same next_url
mechanism already used for listing pagination, so it emits a real
backend-rendered <link rel="next"> tag for SEO, not just a visible link.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd
An upstream the gateway forwards to may itself emit X-Gateway-* headers
(e.g. another DevPlace-style gateway), which can collide with the ones
about to be built for the response. Only X-Gateway-Model is ever trusted
from upstream and relayed as-is - it is the one field an upstream can
legitimately know better than we do (it may have resolved an alias or
served a different pinned version). Every other header (cost, tokens,
latency, context, app-reference) is always our own measurement and is
never overwritten, since blending in an upstream's own accounting would
corrupt the usage ledger's per-model rollups and the quota math built on
top of it.
usage.upstream_reported_model() extracts that one header defensively
(case-insensitive lookup, rejects anything oversized or containing a
control character) and gateway._apply_served_model() applies it, display-
only, at the tail of every response-header build across chat, streaming,
embeddings, images, and passthrough.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd
sync_workspace (user-triggered) and the reconciler's sync_bidirectional_sync
could run concurrently for the same project, and store_upload's read-then-
write on a changed path meant two racing imports each wrote their own blob
while only one ever got referenced - the loser leaked forever. Combined with
no build-artifact exclusion, an actively-compiling workspace hit this
constantly and leaked 5.9M orphan blobs (~96GB) in production before it was
caught.
Closes it at the root: api._sync_dir_bidirectional_locked serializes both
call sites per-project (non-blocking - a project already mid-sync is simply
skipped until the next tick), and IMPORT_SKIP_NAMES/IMPORT_SKIP_EXTENSIONS
keep build output (build/, dist/, *.o, *.pyc, ...) out of the walk entirely.
Recovering what already leaked is a separate concern: a new CLI subcommand
(plus matching make targets) sweeps soft-deleted attachment/project-file
blobs and any blob with zero DB reference at all, plus orphaned container
workspace directories. run_maintenance_cleanup.sh wraps the existing
prune/clear commands for routine disk upkeep.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd
config.STATIC_VERSION becomes f"{APP_VERSION}-{BOOT_ID}": APP_VERSION is
read live from pyproject.toml's version (auto-bumped on every commit by
.githooks/pre-commit), BOOT_ID is the same per-process launch marker as
before (DEVPLACE_STATIC_VERSION env or a wall-clock fallback). The static
asset URL /static/v<version>/... now names the actual commit's version
alongside the boot marker, instead of a bare timestamp.
Fixes nginx/nginx.conf.template's versioned-mount location regex, which
matched digits only (^/static/v\d+/) and would have silently dropped the
immutable, max-age=31536000 cache header for every asset in production
once the version segment carried a dot or hyphen. Verified live against a
running instance that the header still applies to the new URL shape.
Updates README, devplacepy/static/js/CLAUDE.md, and the
/docs/static-caching.html page to describe the new APP_VERSION/BOOT_ID
composition.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwLhnueWrsK15wrieXE5m7
asyncio.run() opens and tears down its own event loop per call, bypassing
the shared background loop tests/conftest.py's run_async uses to refresh
the snapshot cache after each coroutine. Switch provision.py's editor_ready
and view tests to run_async for consistency with the rest of the async
test suite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwLhnueWrsK15wrieXE5m7
pyproject.toml's version has never moved past the 1.0.0 scaffold value
across 368 commits; there was no bump mechanism at all, Claude-driven or
otherwise. Add .githooks/pre-commit (stdlib Python, no dependencies) that
increments the patch version on every commit and stages it automatically,
deferring to a deliberate version edit already staged in the same commit
and skipping merge commits. Wire it in via `make install` (git config
core.hooksPath .githooks) so it activates for every clone without
requiring any change to existing workflows.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwLhnueWrsK15wrieXE5m7
gateway_thinking_dialect overrides the URL-sniffed protocol dialect for a
reverse-proxied upstream (e.g. Ollama) whose URL carries no identifying
token; upstream_capabilities() uses the same effective dialect to stop
sending stream_options to upstreams that don't support it. gateway_allow_client_model
lets a client-requested model name through even with force-model on, for
an upstream that serves many models with no single stable alias.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjW4qocnaJxhugUi5ca8Wo
Ships every completed backup off-box over WebDAV via rclone, verified by
exact byte-size match before local retention or schedule rotation ever
touches it. Fixes prune_orphans to skip confirmed-offloaded backups whose
local copy was already purged (it previously hard-deleted their DB row,
discarding the only pointer to the remote copy). Installs rclone in the
Docker image and gitignores the container's rclone.conf location, which
lives inside the bind-mounted repo root and holds live credentials.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjW4qocnaJxhugUi5ca8Wo
The per-route fallback_model mechanism (used to fail over to a different
provider when the primary upstream errors, e.g. insufficient balance)
looked up the fallback keyed on the client's raw, literal "model" string.
That string only matches a configured route when the caller sends the
exact alias ("molodetz"/"molodetz~embed"/"molodetz-img-small") or another
exact route name - any other value (the common case for external agents,
which rarely echo DevPlace's own alias) resolves no route at all, so
resolve_fallback() returned None and a real 402/5xx from the primary
upstream propagated straight to the caller even with a fallback configured
on the default route.
Fixed in all three handlers (chat/embed/image): the fallback lookup key
now reflects whether a route actually matched the raw request (chat_overlay
result), independent of gateway_force_model - which is always forced true
by a successful overlay and therefore cannot be used to tell "matched a
specific route" apart from "used the default". An unmatched request now
normalizes to the default alias for fallback purposes, so its configured
fallback_model is consulted instead of silently skipped.
Added a regression test that reproduces the exact failure (an unrouted
client model name, primary upstream returns 402, fallback configured on
the default route) and confirms it now recovers instead of surfacing the
402 to the caller.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The container/workspace reachability probes (socket connect + HTTP check)
ran synchronously with real timeouts inside async request handlers and the
live-view relay's 3-4s ticks, freezing the whole event loop whenever a
container wasn't cleanly reachable - the likely cause of the periodic
app-wide stalls. Converted the probe chain (api._port_reachable/_http_probe,
editor_reachable, instance_runtime, provision.editor_ready/view) to real
async I/O and parallelized the admin container/workspace list decorators.
Also fixes: XmlrpcService.on_disable blocked up to 10s on a synchronous
subprocess.wait inside an async method (now matches TelegramService's
async-subprocess pattern); JobService._sweep_expired ran every job kind's
cleanup() - including shutil.rmtree on large directories - synchronously
on every tick, now offloaded via asyncio.to_thread for all job kinds at
once; and several smaller blocking reads/writes on request/service paths
(attachment-to-gitea mirroring, stealth chunked downloads, dbapi/isslop
file reads, job payload/report I/O) moved off the loop thread.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tunnels.create() checked for an existing row by hostname and then
inserted/updated in a separate statement, with no lock between the two.
Concurrent publish calls for the same port (e.g. the editor's Ports
extension re-POSTing on every onDidChangeTunnels event) could race past
the check together and each insert their own row for the identical
hostname. Since routers/tunnel.py resolves a tunnel by a plain
find_one(hostname=...) with no ordering, whichever duplicate it happened
to return decided whether a request reached the app or got a generic
"no tunnel is published at this address" 404 - even while a sibling row
for the same hostname was active and serving traffic.
Replaced the check-then-write with a single atomic
INSERT ... ON CONFLICT(hostname) DO UPDATE, which needs hostname to
actually be unique: idx_tunnels_hostname is now a UNIQUE index instead
of a plain one. Since existing databases likely already carry duplicate
rows from this race, init_db() now runs a one-time
_dedupe_tunnel_hostnames() pass (keeps the best row per hostname -
active > provisioning > pending > failed, then newest) before dropping
and recreating the index as unique - CREATE UNIQUE INDEX IF NOT EXISTS
silently no-ops when an index with that name already exists, so the old
non-unique index has to be dropped first or the constraint would never
actually upgrade.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
make dev/prod hardcoded uvicorn to port 10500 with no way to override it,
so config.PORT (and everything derived from it - INTERNAL_BASE_URL,
Devii's own base URL defaults) stayed 10500 regardless of what port the
process actually bound to. DEVPLACE_PORT now drives all of it, defaulting
to 10500. Docker's existing PORT var (the externally published port via
nginx) is unrelated and untouched; its app container pins DEVPLACE_PORT
to 10500 explicitly so a bare-metal .env value can never leak in and
desync it from the Dockerfile's fixed internal bind port.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The old sync compared only the two live sides (project rows vs workspace
files), so a file present in the project but missing on disk was
indistinguishable from "never materialized here yet" - it always got
re-exported, which is why deleting a file inside a container made it come
back. The mirror direction had the same bug: a file deleted from the
project's file editor was silently re-imported from the container's stale
copy on the next tick.
Fixes it with a persisted per-file sync baseline (new project_file_sync_state
table: db_epoch/fs_epoch as they stood right after the previous sync), the
same role a rsync/Unison state file plays in any real bidirectional sync.
Deleting on either side now propagates to the other, unless the deleted
side's counterpart was edited after the last sync, in which case the edit
wins and the file is restored. A read-only project always exports (never
imports, including on tie) and always removes a workspace's stale local
copy, so it stays a faithful mirror. Sync of an unchanged file is now a true
no-op (zero writes) instead of rewriting it every ~60s tick forever.
sync_dir_bidirectional's return dict gains deleted_in_project/
deleted_in_workspace alongside exported/imported; both API call sites
already pass the whole dict through untouched. The instance sync toast now
summarizes all four counts instead of just imports.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
Admin-unlimited Dev Workspaces: an admin-owned workspace is now exempt from the
max-workspace-count limit, the max-tunnel-count limit, and the whole
idle-stop/idle-warn/retention-delete lifecycle. Resolved once in
quota.resolve() as Limits.unlimited (owner uid checked against
get_admin_uids()), consumed at the three enforcement points
(provision.ensure, provision.publish_tunnel,
WorkspaceService._advance_lifecycle). Also hardens
get_admin_uids()/get_primary_admin_uid() against a partially-schemaed users
table (uid/role column guard), which a fresh test/init_db() path could hit.
AI gateway per-model automatic fallback: any gateway_models route
(chat/embed/image) can now name a fallback_model, picked on /admin/gateway
from a select box of other configured public model names of the same kind
only (never an internal upstream model id). When a route fails after its own
retries are exhausted, the gateway retries once, automatically, against the
fallback's own provider/pricing/key, before any bytes reach the client
(including for a streaming response). One hop only, no chains or cycles;
self-reference and cross-kind fallbacks are rejected at write time.
AI gateway real upstream streaming and thinking-default control: stream:true
is now forwarded to the upstream and relayed to the client as real SSE
chunks (measured TTFT/inter-token latency) instead of a simulated split
response, and every chat/vision call explicitly disables model "thinking" by
default (admin-overridable via gateway_thinking), with per-dialect handling
for DeepSeek, OpenRouter, and Ollama.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
Every AI-calling feature (content correction, the @ai modifier, quiz grading,
SEO metadata generation, the AI Usage Analyzer, and DeepSearch) can now name
its own model via admin-editable configuration, defaulting to the gateway's
default model when left blank. The gateway a feature talks to stays fixed to
the internal endpoint either way, only the model name is a knob, so the best
model can be picked per task.
Also splits the news service's shared AI grading/formatting config into two
independent endpoint/model/key pairs: reformatting no longer requires
repointing (and thereby breaking) the free, scoring-only grading model.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZ5x6KZTxZjbJqsEkxGbxG
- Route Devii-driven AI gateway cost to the action/tool that triggered
it instead of a blanket "internal" bucket, so per-feature AI spend
is attributable.
- Fix the quiz attempt review to show one previously-answered question
at a time instead of all of them at once, and stop a quiz endpoint
linked from the quiz flow from responding with raw JSON.
- Add DB API async query result route and AI Usage Analyzer annotated
source/media routes, with traversal-safe uid/path handling and
matching tests.
- Add Code Farm action audit logging (plant/harvest/buy-plot/upgrade/
fertilize) and related admin workspace/services/trash/gateway route
and doc touch-ups.
- Drop redundant docstrings from access_tokens.py per the no-comments
convention.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
- Fix StatisticsOut to match the real admin_statistics page context
(active_tab/tabs/window_hours/initial), which was raising a
ValidationError and 500ing GET /admin/statistics with
Accept: application/json.
- Add missing *Out fields dropped from JSON responses: IsslopSourceOut
(source_lines, marked_lines, focus_line, report_url), IsslopReportOut
(report_url, badge_url, events_url, topic), IssuesOut
(viewer_is_admin), GameStateOut/GameFarmViewOut (game_error),
QuizAttemptPageOut (answer_max_chars, quiz_error), QuizBuilderOut
(quiz_error).
- Convert GET /admin/issues/planning to respond() with a new
AdminIssuesPlanningOut schema so it serves JSON like every sibling
admin dashboard, and document it in docs_api.
- Document previously-undocumented admin endpoint families in
docs_api: services page routes, gateway provider/model CRUD, admin
workspaces (11 routes), and trash list/restore/purge.
- Correct stale references to services/devii/actions/catalog.py as a
single file; it is the actions/catalog/ package.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
Notifications: a new "thread" type notifies every other commenter on a
post whenever anyone comments on it, disregarding reply hierarchy -
excluding the actor and whoever already got a comment/reply
notification for that same event, so no one is double-notified.
Implemented via a background-deferred fan-out mirroring the existing
mention-notification pattern.
SEO: discussion_forum_posting() now embeds up to 20 of a post's
comments as nested schema.org Comment entities (not just an aggregate
count), and a new /topics hub plus /topics/{topic} pages give the
feed's topic filter real, independently crawlable/indexable URLs -
/feed?topic=X was never indexable since its canonical strips the
query string back to bare /feed. Both are wired end to end (schemas,
Devii actions, docs API, sitemap, locustfile load-test coverage).
Quiz player: the auto-advance to the next question used to hide the
just-answered slide in the same tick as rendering the grade, so on
any multi-question quiz the Correct/Not correct feedback was never
actually visible before the view moved on. Delayed via setTimeout,
with the pending timer cleared on manual navigation and on
disconnect so it can't race or fire on a removed component.
Also includes other local changes already in progress in this
working tree before this session (messaging, push delivery,
deepsearch jobs, game economy, quiz builder) - verified by the full
suite passing (3467 tests) but not authored or individually reviewed
in this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
Composer focus/blur no longer fights the on-screen keyboard: tapping
Send blurred the textarea first, which unconditionally zeroed the
visualViewport keyboard-inset compensation before the immediate
refocus could recompute it, so the composer could sit misplaced for
up to 600ms after every mobile send. Blur now only resets the inset
when focus is actually leaving the composer form, and focus
recomputes it immediately instead of waiting on the retry timers.
Emoji picker and @mention wiring, previously only ever applied once
at page load, are now re-run whenever AppChat rebuilds a composer for
a client-side conversation switch, so both survive tapping a
conversation from the list instead of only working after a full page
reload. Viewport meta gains interactive-widget=resizes-content for
native keyboard-aware layout. Chat scroll panes get
overscroll-behavior: contain plus momentum scrolling, matching the
pattern already used elsewhere in the app. The conversation-search
dropdown's literal z-index is replaced with the --z-popover token.
Adds sticky Today/Yesterday/date separators between message groups
(chat/DateSeparators.js, viewer-local-time day boundaries, normalized
on every thread mutation) and a debounced "Reconnecting..." banner
reflecting live WebSocket state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qsdit8UXhbUn9ZnfgjbXqt
The auth matrix probes documented endpoints with their documented form
params; the faction param was declared with location body instead of
form, so the anonymous probe sent an empty body and hit 422
validation before the auth guard. And the first fight always triggers
a lead-change event that outranks the fight event at the top of the
ticker, so the e2e flow now asserts on the ticker as a whole.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new post attachment type beside polls: the composer gains a Start
Opinion War builder (same disabled-inputs opt-in as the poll builder)
that names exactly two factions; the battle runs for exactly 7 days
from post creation. Members join a side, may defect at any time
(damage already dealt stays with the faction it was dealt to), and
fight once per 24 hours per battle. A fight spends 25 Code Farm coins
and deals deterministic level-weighted damage: 100 + 10 * min(level,
20) HP, so a newcomer deals 110 and a veteran caps at 300 - no
randomness anywhere.
The battle renders on the post card as a CSS pixel-art battlefield
(box-shadow sprites: castles, faction flags, marching soldiers, a
flickering campfire; steps() animation, disabled under reduced motion)
with live HP bars, a countdown, the viewer's faction strip, top
contributors and an event ticker. Live frames ride pub/sub on
public.battle.{uid} via a relay on the service-lock owner, with the
durable opinion_war_events trail (per-war atomic seq) as the source of
truth and a 15s incremental poller as fallback. /battles lists battles
with active/ended/mine filters, search and pagination.
Every mutation is a conditional UPDATE via conditional_update_row: the
fight sequence claims the cooldown first, then spends coins, then lands
the damage, compensating earlier steps on any later refusal so a crash
costs a turn, never coins. Resolution is lazy on read (no cron):
an exactly-once CAS computes the winner in the statement, awards XP
(participation, winner bonus, top damage dealer bonus; draws pay
participation only), emits the result event and notifies fighters. The
OpinionWarService backstop resolves unviewed wars and sends
fight-ready notifications, exactly-once via a marker CAS.
Fan-out: battle notification type, four badges, audit keys
(battle.create/join/switch/fight/resolve), Devii actions (join/fight
confirm-gated), API docs group, docs prose page, sitemap and topnav
entries, REPORTABLE_TARGETS registration, post-delete cascades,
README and nested CLAUDE.md documentation.
Verified with the four-layer procedure: property checks over the full
damage domain, 1200-step stateful fuzz (hp-sum invariant, coins never
negative, resolved totals frozen), and real 8-process races proving
exactly-once semantics for concurrent fights, double-spends across two
wars, resolution XP and double-joins. Persisted tests in
tests/unit/services/opinionwar, tests/api/battles, tests/e2e/battles
and tests/api/posts/create.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_attachment_display.html iterates a context variable named
`attachments`, so every caller binds it before the include.
_post_card.html was the one caller that did not: it guarded on
item.attachments but included the partial with nothing bound, so the
gallery looped over whatever `attachments` happened to be in the
surrounding page context and rendered empty. Every post with an image
looked image-less on the feed and on profiles, and on a project page -
where project_detail.html sets `attachments` at template scope for the
project's own files - a devlog card would have rendered the project's
files as its own.
With the image actually reaching the card, render a lone one properly:
a gallery holding exactly one item gets a `single` class and takes the
full content column (max-height 480px, object-fit contain, no hover
scale), matching the original DevPlace. That branch serves the stored
original rather than thumbnail_url, because a thumbnail is 200px on its
longest side and stretching it to the column width is visibly blurry.
Animated GIFs needed no change and now have a test proving it: they
never had a thumbnail to flatten, so they already took the original-file
path and simply render larger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.
The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.
It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.
Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Outcome: done
Changed: tests/e2e/iosapp.py:1-94 (new file)
Verified by: python -m pytest tests/e2e/iosapp.py -q -> 5 passed (3 consecutive clean runs, incl. verify()); full make test -> 3348 passed, 1 skipped, 11 failed (9 tests/api/projects/workspace.py FileNotFoundError 'docker' = missing docker binary in container, pre-existing per sibling run; 1 tests/e2e/game/farm.py steal flake, passes standalone; 1 was this file's pre-fix settings-propagation race, fixed)
Findings: tests/e2e/iosapp.py locks the badge behaviour: footer badge visible on landing (.landing-footer) and /feed (.site-footer), guest topnav badge next to Login/Sign Up, mobile-panel badge after opening the hamburger, every href equals the configured ios_app_url with target=_blank rel=noopener noreferrer, and all badges disappear when ios_app_url is cleared (restored in try/finally).
Settings-flip e2e tests must sleep CACHE_VERSION_PROPAGATION_SECONDS (1.5s) after set_setting before navigating: the server caches the cache_state version for 1s (core.py:56), and the first iosapp run hit that race (badge absent on a stale render).
On a fresh DB the operational_defaults block incl. ios_app_url is never seeded because it is gated on the pre-init db.tables snapshot (schema.py:34 vs :1705), so the badge renders only after an admin sets ios_app_url.
Open: re-run make test on a docker-capable host to clear the 9 workspace.py environment failures; decide the fresh-DB ios_app_url seeding gap (PM/implementation node); farm s
Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: e8e69f65a5fa40c2bf6a8b0206cfb2d4
Typosaurus-Agent: @sveta
Refs: #169
Outcome: done
Changed: devplacepy/templates/base.html:126-132,189-195; devplacepy/templates/_footer_links.html:12-19; devplacepy/templates/admin_settings.html:33-38; devplacepy/routers/admin/settings.py:19; devplacepy/templating.py:197-199,218; devplacepy/static/css/base.css:720-731,785-793,1151-1161,1210-1212; devplacepy/database/CLAUDE.md:224,228
Verified by: import gate clean; py_compile clean; ruff/pyflakes no new findings; TestClient smoke (badges present with href=ios_app_url, target=_blank rel=noopener noreferrer, static_url-versioned src on /, /feed, /leaderboard; absent when setting empty; admin clear via /admin/settings POST verified); make test-fast: 2659 passed, 1 skipped, 10 failed - all environmental (9 missing docker binary, 1 missing libcairo.so.2), none caused by this change; Playwright e2e inapplicable (chromium cannot launch: libglib-2.0.so.0 missing)
Findings: Badges render only when ios_app_url site setting is non-empty and are editable/clearable via AdminSettingsForm (CLEARABLE_SETTINGS). Researched URL https://apps.apple.com/app/devplace/id6797215143 (trackId 6797215143, bundleId wok.DevPlaceIOS) confirmed via iTunes Lookup API and seeded in schema.py operational_defaults by sibling commit. Topnav badge hides at <=1024px where the mobile panel badge takes over, keeping the 360px layout overflow-free. Badge SVG is Apple's official white variant vendored at devplacepy/static/app-store-badge.svg with retoor header. Apple badge marketing guidance (40px min
Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 2817fd803023406699936e88b76ee632
Typosaurus-Agent: @nadia
Refs: #169
Outcome: done
Changed: none
Verified by: not verified - review-only node; repo cross-checks done (devplacepy/templates/docs/contact.html:5, devplacepy/templates/admin_settings.html:192, devplacepy/database/schema.py:1746, devplacepy/static/app-store-badge.svg)
Findings: Verdict: accept with findings - every parent acceptance criterion is addressed by sourced, tiered findings; none of the notes below is blocking.
The verified URL is already consumed in-flight: uncommitted ios_app_url default at devplacepy/database/schema.py:1746 plus AdminSettingsForm field (devplacepy/models.py) and ios_app_url() Jinja global (devplacepy/templating.py); later nodes must not re-introduce it and must review that diff separately.
Apple's official badge SVG is vendored at devplacepy/static/app-store-badge.svg (title Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917, 119.66407x40), matching leaf-2's source recommendation; it sits at the static root rather than the leaf's suggested static/vendors/, a consistency-review call, and the leaf's own commit declared "Changed: none" despite adding it.
"Confirmed live" evidence is DuckDuckGo-indexed storefront snapshots (/fr/, /th/, /il/) via the sanctioned aggregator rsearch.app.molodetz.nl, direct egress to apps.apple.com being blocked and disclosed; corroborated by github.com/WilhelmOks/DevPlaceIOS, so a manual click-through before release is advised.
Apple's badge terms (min 40px onscreen height, one badge per layout) vs the ticket's "small badg
Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 0c6857c89c9448589c0d0141ee774948
Typosaurus-Agent: @zoya
Refs: #169
DeviiTerminal bound [data-devii-open] click listeners only after the
async /devii/session fetch resolved, silently dropping early clicks.
Switch to a single delegated document listener bound in the
constructor, matching the ModalManager/dp-lightbox pattern.
The steal-confirm and comment-vote e2e tests asserted DOM state right
after a click with no wait for the triggering POST to land, racing the
server under CI load. Wrap those clicks in page.expect_response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A shared-server crash mid-suite previously cascaded into hundreds of
opaque connection-refused errors across every later api/e2e test,
making the real cause invisible. pytest_runtest_setup now polls the
tracked subprocess and, on the first test after it exits, reports the
exit code plus the server's own log tail once instead of forcing every
subsequent test to fail blind.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Test-process reflections of the projects table cached a reduced schema
when they ran before the app server had ALTER-TABLE'd in website_url,
repo_url, cover_attachment_uid, logo_attachment_uid, platforms,
release_date, and demo_date, causing spurious KeyErrors under full-suite
ordering.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>