Compare commits

...
Author SHA1 Message Date
typosaurus f37d9dbf47 test(sveta): Extend vote-state tests to assert the non-color cue
DevPlace CI / test (pull_request) Failing after 13m45s
Outcome: done
Changed: tests/conftest.py:293-300, tests/e2e/post.py:5,74-157, tests/e2e/posts/index.py:17,396-418, tests/e2e/notifications/index.py:313,799, tests/e2e/leaderboard.py:42
Verified by: python -m pytest tests/e2e/post.py tests/e2e/posts/index.py tests/e2e/notifications/index.py tests/e2e/leaderboard.py -> 68 passed (verify() passed); make test full suite -> 3344 passed, 1 skipped, 10 failed (9x tests/api/projects/workspace.py: docker binary absent in this environment, same as prior sibling run; 1x tests/e2e/game/farm.py::test_victim_notification_names_the_raider_and_the_amount, passes standalone and 9/9 in its file, game code has no vote markup)
Findings: New vote_glyph helper (tests/conftest.py:293) reads getComputedStyle(el,'::before').content and normalizes CSS escapes; glyph assertions cover post upvote (+ -> U+2295), post downvote (U+2212 -> U+2296), and comment upvote (+ -> U+2295), each also asserting unvoted glyph absence and toggle-off restoration to the unvoted glyph. New test fails against pre-change code: on 72e11db, test_post_upvote_voted_state_persists fails with "assert 'none' == '+'" (no ::before rule existed; buttons held literal text). Fixed all three has_text="+" vote locators (notifications/index.py:313,799, leaderboard.py:42) to class-based button.post-action-btn.vote-up; the three tests now pass. test_comment_voted_state_persists was made deterministic by wrapping the click in expect_response (pre-existing reload race); it now passes reliably

Typosaurus-Run: c36faeceb55d45b6bf38a45eec4f47ab
Typosaurus-Node: 9b6a5ec294a2472e800fbfeed775b27b
Typosaurus-Agent: @sveta
Refs: #162
2026-08-16 05:02:12 +02:00
typosaurus 41b4181ba1 feat(zoya): Review the vote-button change map against the acceptance criteria
Outcome: done
Changed: none
Verified by: e2e run attempted (3 tests) but blocked at browser launch by missing libglib-2.0.so.0 (environment fault, not a result); breakage proven from Playwright source elementText() in driver coreBundle.js plus empty-button markup
Findings: REJECT - the implementation empties all four vote buttons (_post_votes.html:4,9; _comment.html:6,11) and moves glyphs into CSS ::before, so has_text="+" locators at tests/e2e/notifications/index.py:313, :799 and tests/e2e/leaderboard.py:42 match zero elements (Playwright text matching reads DOM text nodes, never ::before content) and those 3 tests time out
Findings: commit 625940a claimed "Changed: none" but deleted the glyph text from post-downvote and comment-upvote, leaving HEAD with two glyph-less vote buttons (regression only repaired by the uncommitted diff adding the ::before rules)
Findings: map line references are accurate for 625940a^ (text glyphs, no ::before rules) but stale for the working tree, where glyph rules are feed.css:193-217 and post.css:141-165
Findings: the voted cue itself satisfies the ticket - `+`->U+2295 and U+2212/U+002D->U+2296 under .voted (feed.css:211-217, post.css:159-165), non-color, consistent with the star precedent base.css:1319-1325, with aria-pressed synced client-side (VoteManager.js:60-62)
Findings: unvoted downvote glyphs remain inconsistent in touched rules: post U+2212 (feed.css:198) vs comment U+002D (post.css:146)
Open: fix the 3 e2e locators to class-based .vo

Typosaurus-Run: c36faeceb55d45b6bf38a45eec4f47ab
Typosaurus-Node: ebdbae6c04a84a239287bd62262cb254
Typosaurus-Agent: @zoya
Refs: #162
2026-08-16 05:02:12 +02:00
typosaurus 26e97adc48 feat(mila): Confirm the star precedent, enumerate test dependencies, recommend the non-color
Outcome: done
Changed: none
Verified by: not verified (read-only node; no files changed)
Findings: RECOMMENDATION TO BUILDER: swap the glyph in the voted state only — upvote `+` (U+002B) → `⊕` (U+2295), downvote `−`/`-` (U+2212/U+002D) → `⊖` (U+2296) — via template conditionals in _post_votes.html:4,9 and _comment.html:6,11 plus a VoteManager.js render() textContent mirror scoped to `.vote-up`/`.vote-down` only (VoteManager also binds `.vote-star` forms, whose `::before` glyph must not be overwritten).
Findings: Precedent confirmed at base.css:1319-1325: `.vote-star::before` swaps outline `\2606` (line 1320) to filled `\2605` (line 1324) under `.voted`; templates/CLAUDE.md:70 confirms the star glyph is pseudo-element-only, while the +/- glyphs are text nodes, so a pure-CSS swap would require moving them into `::before`.
Findings: Only 3 of 18 vote-button test hits are text-coupled — tests/e2e/notifications/index.py:313, :799 and tests/e2e/leaderboard.py:42, all `button.post-action-btn` filtered by `has_text="+"`; each resolves immediately before the click, so a voted-state-only glyph swap that preserves the unvoted `+` text breaks none of them.
Findings: The other 15 hits (posts/index.py:407,413; projects/devlog.py:133-134; feed.py:687; post.py:63,70,80,83,87,97,100,104,187) and all comment tests (post.py:117-124,218-221,274-278) use `.vote-up`/`.vote-down`/`.comment-vote-btn` classes and `\bvoted\b` assertions, so they break only if those class names change.
Findings: Empiri

Typosaurus-Run: c36faeceb55d45b6bf38a45eec4f47ab
Typosaurus-Node: 9f61b1db85c147a6b2b8376a6c8ae798
Typosaurus-Agent: @mila
Refs: #162
2026-08-16 05:02:12 +02:00
retoor d7d489681a Update
DevPlace CI / test (push) Failing after 1h32m13s
2026-08-16 05:00:52 +02:00
retoor 62910b0726 Update
DevPlace CI / test (push) Failing after 29m48s
2026-08-16 04:01:17 +02:00
retoorandClaude Sonnet 5 8db0efff29 Fix Devii open-trigger race and close two flaky e2e waits
DevPlace CI / test (push) Failing after 1h30m15s
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>
2026-08-16 02:02:34 +02:00
retoor fa8751a4ca Uppdate
DevPlace CI / test (push) Failing after 1h27m55s
2026-08-15 20:38:24 +02:00
retoorandClaude Sonnet 5 682be0861f Fail fast with one clear diagnostic when app_server dies mid-session
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>
2026-08-15 20:25:39 +02:00
retoorandClaude Sonnet 5 72e11db185 Ensure full projects column set in init_db to fix cross-process schema drift
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>
2026-08-15 00:18:04 +02:00
retoor 37d23e8581 Update
DevPlace CI / test (push) Failing after 41m14s
2026-08-14 03:20:29 +02:00
retoor b97b5a7854 Waw 2026-08-13 12:59:53 +02:00
retoorandClaude Sonnet 5 45ad8e79ed Revert background color tokens to the original dark palette
PR #164 shifted --bg-primary/secondary/card/input/modal and the body
gradient to a purple-toned palette; restore the prior values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:59:53 +02:00
retoor 11c0cc66cc pdate 2026-08-13 12:59:53 +02:00
retoor 6514261730 Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.

Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.

The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.

Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.

Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.

Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.

Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-13 12:59:53 +02:00
retoor ecb22f2b2d Merge pull request 'Dedicated project page' (#166) from blindxfish/devplacepy:project-page into master
DevPlace CI / test (push) Failing after 1h23m34s
Reviewed-on: #166
2026-08-13 12:49:33 +02:00
blindxfishandClaude Opus 5 265cb781f9 Match the cover and logo upload filter to the dp-upload contract
DevPlace CI / test (pull_request) Has been cancelled
The cover and logo widgets declared allowed-types as bare extensions
(png,jpg,jpeg,gif,webp), but dp-upload builds the candidate extension
with a leading dot before testing membership, so every selected file
was refused with "type is not allowed". The four widgets were the only
hardcoded lists in the codebase: every other call site passes
allowed_file_types(), which defaults to empty and therefore disables
the client filter entirely, which is why nothing else exposed the
mismatch.

Rather than dotting a duplicated literal in four places, the effective
list now comes from a new allowed_image_types() Jinja global that
intersects allowed_extensions() with IMAGE_EXTENSIONS. That reuses the
one server-side choke point, so the widget can never advertise a type
the upload gate would reject, and narrowing the admin allowed_file_types
setting narrows these widgets with it. IMAGE_EXTENSIONS rather than
POST_IMAGE_EXTENSIONS is the correct set here because the route guard
is _hero_attachment_uid, which accepts any is_image attachment, and
bmp/tiff both upload and pass it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 08:29:35 +02:00
blindxfishandClaude Fable 5 72e088c160 Dedicate the project page to the project
DevPlace CI / test (pull_request) Has been cancelled
The project detail page becomes a full project showcase built entirely
from existing platform mechanisms. One encompassing dark card wraps the
page; inner panels (tab bar, sidebar cards, devlog entries, comments)
sit one elevation lighter. The hero opens with a cover banner and an
optional logo tile, both plain attachment references
(cover_attachment_uid/logo_attachment_uid) uploaded through the
standard dp-upload attachment widget and linked via the existing
link_attachments choke point - the route validates each uid belongs to
the actor and is an image, and an empty value on edit keeps the current
one. The title block, type/platform chips and author row overlay the
banner behind a scrim with a dark text shadow, next to an owner-set
Visit Website CTA; website_url and repo_url are normalized in models
and render with rel noopener nofollow.

An anchor tab bar (Overview, Devlog, Screenshots when present,
Comments, Files) navigates the page. The main column keeps About, the
devlog timeline (with devlog_count and an owner Post update button
opening the shared composer preset to the devlog topic + project - the
form now lives once in _post_composer_form.html, included by feed.html
and project_detail.html), a Screenshots gallery built from image
attachments minus the cover/logo (thumbnails, lightbox, 12 rendered),
and the comment thread; the sidebar holds Links, Stats and the Author
card. Owners add gallery images from the More menu via
POST /projects/{slug}/screenshots (owner-only, audit
project.screenshots.add, Devii action project_add_screenshots, docs id
projects-screenshots). comment_count/devlog_count ride
ProjectDetailOut, the new fields ride ProjectOut, and the create/edit
faces (modals, Devii actions, API docs) carry them. The project
comment/files e2e tests scope their locators per the documented
dual-control idiom, and new unit/api/e2e tests cover URL normalization,
the counts, the hero attachment guard, the screenshots flow and the
preset composer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 23:00:12 +02:00
retoor 782bcec5bc ipdate
DevPlace CI / test (push) Failing after 1h9m6s
2026-08-10 03:12:43 +02:00
retoor f3b91ac75b Merge pull request 'Align the platform look and feel with the devplace main-branch design' (#164) from blindxfish/devplacepy:pr1-terminal-theme into master
DevPlace CI / test (push) Failing after 1h8m25s
Reviewed-on: #164
2026-08-10 00:28:42 +02:00
retoor 6cac64a3f6 Update
DevPlace CI / test (push) Has been cancelled
2026-08-10 00:23:20 +02:00
retoorandClaude Opus 5 2bdcf6528f Refuse to touch the production database without stated confirmation
data/devplace.db is the live database and make dev, make prod and the
Docker stack all share it, so an agent-initiated command that reaches it
is a production incident waiting for a typo. The hazard is invisible in
the command text: the script that prompted this named no path at all, it
imported devplacepy and therefore resolved config.DATA_DIR to the real
file. A path-pattern rule would have sailed straight past it.

The PreToolUse hook reads the script and judges it on content, so one
that points DEVPLACE_DATABASE_URL at a scratch file passes and an
unguarded one does not. It also refuses commands naming the database or a
production data directory, the management CLI, and python -m devplacepy.
The suite, the server targets and the mandated import gate stay free.
permissions.deny additionally refuses Write and Edit anywhere under data,
which the Bash hook cannot see.

The escape hatch is two-factor and cannot be self-served: without
confirmation the command is denied outright rather than prompted, and the
token that downgrades it to a prompt may only be added after the user has
confirmed in their own words. Verified against thirty-five commands, and
the heuristic is narrower than it looks because the repository path
itself contains the package name, so it matches an import statement
rather than the bare word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:18:10 +02:00
retoorandClaude Opus 5 7e37122f9f Converge every account onto every policy agreement it has not declined
An instance kept production-identical for extended manual testing is
otherwise taxed forever by its own safety controls: five consents, a
versioned terms gate on every mutating request, and every account
predating the trust and safety commit reading terms_version NULL because
init_db deliberately never backfills it. AcceptanceService grants each
agreement to each account that has not declined it, so the instance stays
production byte for byte while nobody clicks the same dialog again. It is
opt-in, dry run by default, and one switch per agreement type.

The application is not allowed to know it exists. One registration line
in main.py is the only import anywhere, there is no route, schema,
template, Devii tool or environment flag, and a unit test greps the tree
and fails the suite if a second importer appears. The decline register
needs no storage: the ledger is append-only in effect, the service only
ever grants, so any withdrawn row was written by a human and that pair is
never touched again. No provenance column, nothing to observe.

Satisfaction is the gate's own expression, never a proxy, which is why
the ordering is created_at then id exactly as consent_state selects, and
why the live-account clauses are built with has_column: init_db ensures
terms_version and deletion_requested_at but not is_active, so a hardcoded
reference raises no such column on an instance where nobody was ever
suspended. Every write is one conditional statement decided on the real
rowcount, proven with sixteen processes racing one account to exactly one
ledger row and one audit row. The two existing audit keys carry it, with
actor kind service, because a service that silently mutated consent state
would be the worst possible exception to the append-only rule.

lensfl.md is the source brief accept.md records the design against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:17:47 +02:00
blindxfishandClaude Fable 5 3fca4be72e Flatten control accents: no gradient or glow on buttons, tabs, or the FAB
DevPlace CI / test (pull_request) Has been cancelled
The reference design uses flat primary buttons, so every interactive
control drops the brand gradient and the accent glow: .btn-primary
(flat accent, accent-hover on hover), the feed nav tabs, the projects
tabs, the sidebar active link, and the create-post FAB (which keeps a
neutral elevation shadow). Gradients remain only on decorative meters
and stripes (XP fills, progress bars, the project card top stripe).
The now-unused --glow-accent token is removed, and deepsearch.css
loses its var() fallback on the progress gradient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:36:51 +02:00
blindxfishandClaude Fable 5 c09e6b328c Fix the member home layout: align the Code Farm promo, fill sparse post rows
The signed-in dashboard is a 1200px two-column grid, but the shared
Code Farm promo section stayed at the guest page's 960px centered
measure, so it floated misaligned under the dashboard columns. A
landing-game-dashboard modifier (applied only when a user is signed
in) stretches it to the dashboard's own measure and gutters. The
Latest Posts grid becomes repeat(auto-fit, minmax(320px, 1fr)) so a
single post fills the row instead of leaving a dead half-column while
two or more still lay out as the original two columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:32:53 +02:00
blindxfishandClaude Fable 5 2921edd7f3 Retarget the theme to the devplace main-branch design (indigo + rust)
The reference design is the devplace Next.js main branch, not the
terminal branch the previous commit tracked. Swap the token values to
that design language: deep indigo page (#271b5b) over near-black indigo
cards (#13112a/#1a1736), #11101f hairlines, purple-tinted text ramp
(#e9e4ff/#c8c3e5/#9b93c9), burnt-orange primary accent (#b73f1e) with
a raspberry hover (#af3050) and a rust-to-plum brand gradient, white
on-accent text, soft rounded radii (8/12/16px), the reference's diffuse
soft/medium/strong shadows, a warm accent glow, and Trebuchet MS as the
UI font (system font - the vendored JetBrains Mono files are removed
along with their base.html link).

Follow-through updated to match: theme-color meta + PWA manifest
(#271b5b), offline page, avatar fallback SVG, statistics chart palette
(brand ramp), chat light-theme overrides, OG image + app icons
regenerated in the new palette, and the /docs/styles-colors guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:22:32 +02:00
blindxfishandClaude Fable 5 c19ff19de2 Align the platform look and feel with the devplace.net terminal design
Retheme every token in variables.css to the terminal design language:
near-black surfaces (#0a0a0f/#111118/#1a1a24), terminal green accent
(#22c55e) with dark on-accent text, gray text ramp, #222233 borders,
sharp 4-8px radii, neutral depth shadows, and JetBrains Mono as the
primary UI font (vendored latin woff2, no CDN).

Follow-through outside the token file: white-on-accent buttons/tabs now
use the new --on-accent token for contrast, three var() fallback
violations shipping the old palette are gone, the dead --bg-hover token
is replaced with --bg-card-hover, avatar fallback SVG, statistics chart
palette, chat light-theme overrides, offline page, PWA manifest,
theme-color meta, OG image + app icons regenerated in the new palette,
and the /docs/styles-colors style guide now documents the new values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 20:55:46 +02:00
retoor 1a5fc9428a FUCKER
DevPlace CI / test (push) Failing after 1h3m34s
2026-08-09 11:39:53 +02:00
retoorandClaude Opus 5 c0742994cd Make the workspace editor reachable through the sub-path proxy
code-server runs authenticateOrigin on every websocket and resolves the
request host as Forwarded, then X-Forwarded-Host, then Host. The forward
core put the public host into additional_headers, but the websockets
client already writes its own Host for the real TCP target and Headers
appends, so the handshake carried two Host lines; Node keeps the first
(the internal gateway:port), the origin check failed, and code-server
answered 403. Because the browser socket was accepted before the upstream
was dialled, that surfaced as a 101 followed by 1011 and the editor died
on "the workbench failed to connect to the server". Dialling first and
carrying the public host in the connect URI fixes both planes.

The two header builders that had drifted apart are now one core, so a
websocket carries the same client and forwarded headers as an HTTP
request. Responses stream instead of buffering whole, which is what makes
a large tunnel download cost constant memory and lets SSE work; byte
accounting moved onto the completion callback. Subprotocols negotiate,
the upstream client is reused across requests, and the path and query are
forwarded byte-exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:26:17 +02:00
retoorandClaude Opus 5 91fac7fd67 Gate blocked actions behind an in-place terms acceptance dialog
A member whose account has not accepted the terms in force now gets one
dialog on the action they attempted instead of a dead-end refusal. The
client handler is the single TermsGate, wired into every Http POST helper
so the four optimistic controllers cannot swallow the gate into an error
flash, and the original request is replayed once the acceptance is
recorded. Reading the site and deleting an account stay unblocked.

apple.md is the source brief the compliance research documents reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:25:57 +02:00
retoor 8e9d3fad98 Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.

Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.

Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.

Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.

Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.

Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.

Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
retoor 68c2bbe387 Share one secret display style and add the architecture atlas
DevPlace CI / test (push) Failing after 1h0m46s
The API key block and the workspace editor password each carried their
own copy of the same monospace, break-all, boxed styling. Both now use a
single secret-value class in base.css, so a new secret surface inherits
the presentation instead of duplicating it.

ARCHITECTURE.md is a structural overview of the application derived from
the source tree, the live schema and the imported app object, linked
from the README.
2026-08-08 13:52:11 +02:00
retoor 372067bbe4 Order the workspace editor certificate at creation
A new tunnel row was inert at pending until the next WorkspaceService
tick picked it up, so the editor certificate waited up to 30 seconds
before the ACME order even started. Opening a workspace and reaching its
public hostname in that window served the wildcard default certificate
and produced ERR_CERT_COMMON_NAME_INVALID in the browser.

certs.certify now owns the whole transition - provisioning, issue,
active or failed, then the owner notification - and both callers use it.
provision.ensure schedules it for the editor tunnel the moment the
workspace exists, claiming the row synchronously so the tick can never
issue the same host twice and burn a duplicate-certificate slot. The
service loop keeps calling certify per pending row as the safety net for
user-created tunnels and for rows created while molohttp was
unconfigured.

Issuance is a loop task rather than an await: an order takes about ten
seconds and certs.issue allows up to 180, which would hang the workspace
POST. The task is held until it completes so it cannot be collected mid
flight.

resume also restarts the idle clock, and the workspace routes gain
HTTP-level tests covering every refusal path they had none for.
2026-08-08 13:52:02 +02:00
retoor 56becfb3f7 Answer workspace refusals instead of crashing on them
json_error takes (status_code, message). Every refusal in the two
workspace routers passed them the other way round, which builds a
JSONResponse with a string status and raises TypeError inside
Response.init_headers before a byte is written. The branch meant to
explain a limit to the user returned 500 instead.

A member already holding the default two workspaces therefore got a 500
from the workspace page's Open workspace button rather than the quota
message. Twelve call sites corrected across both routers.

workspace_open also let ContainerError escape as a second 500 on the
same button when the ppy image is not built; it now returns 400 through
the shared fail helper.

tests/unit/responses.py AST-scans the whole package for both argument
orders, so the swap cannot reappear anywhere.
2026-08-08 13:51:50 +02:00
retoor 192df12b1d Make dev workspaces serve a working browser IDE end to end
The workspace feature shipped its routes, agent tools and docs, but the
editor was never reachable: the project page had no entry point, the ppy
image had no code-server binary, no certificate was ever requested for a
tunnel, and both nginx and the proxy dropped what the editor needs.

- Add a VS Code button to the project action row and a Workspace item to
  the overflow menu, gated by can_open_workspace plus a running instance
  (viewer_can_workspace and workspace_editor_url on ProjectDetailOut).
- Install a pinned code-server in ppy.Dockerfile before USER pravda and
  assert it in the build smoke test, so an image that cannot run the
  editor no longer builds green.
- Run the editor with --auth password and a per workspace 8 character
  pronounceable secret, minted once at the ensure_editor_password choke
  point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the
  admin listing shares.
- Publish the editor tunnel when a workspace is created and issue its
  certificate from a new WorkspaceService phase against the molohttp admin
  API, then notify the owner with the live URL and the password. Only
  pending rows are retried, so a broken host cannot burn the ACME failure
  rate limit. Renewal stays molohttp's job.
- Forward the original Host on proxied requests and the client cookie on
  proxied websockets, so code-server scopes its session cookie to the
  public hostname and authenticates the workbench socket.
- Recreate a container stuck in the created state instead of retrying
  docker start forever against an image it can no longer run.
- Return a JSON string from WorkspaceController.dispatch; raw dicts landed
  in a tool message and aborted the turn at the model endpoint.
- Let the nginx catch-all carry websocket upgrades, keeping upstream
  keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
retoor 21f6ae0615 iUUUUpdatexz
DevPlace CI / test (push) Failing after 1h2m3s
2026-08-07 10:53:43 +02:00
retoorandClaude Opus 5 b777a5b9d0 Make the presence roster the single source of truth for online status
Online status had three server-side candidate populations and two
client-side deciders, so the feed roster and the /messages indicators
could legitimately disagree.

PresenceRelayService built its online set from whichever topics happened
to be subscribed on a given tick: the roster candidates on /feed, only
the per-uid dot rows on /messages. Different populations meant a
different hysteresis baseline, so the same user could be online in one
place and offline in the other. On top of that, PresenceManager re-derived
online status client-side from a frozen data-presence-last-seen with a
strict timeout and no hysteresis, re-evaluated every 20s, so any element
whose relay frame was missed drifted grey after the timeout and stayed
there. AppChat carried a third renderer with its own PubSubClient that
only ever wrote online/offline, plus hand-built dot markup duplicating
_presence_dot.html.

The relay now collapses to one set on one topic. Each tick it reads the
online population in a single indexed query (online_candidates, capped by
the new PRESENCE_TRACK_LIMIT), applies hysteresis once, and publishes
{count, online, users} on public.presence.roster only when the uid set
changes. online is the authority for every avatar dot; users is the same
set trimmed to PRESENCE_ONLINE_LIMIT for the feed panel. The per-uid
public.presence.{uid} topics are gone, which also removes one
subscription per distinct author on a page.

is_online(user) is now stays_online(seconds_since(last_seen), False), so
the server-rendered initial state and the live set apply one formula.

PresenceManager makes one subscription and renders every
[data-presence-uid] element as membership of that set, with no clock and
no expiry timer; before the first frame the server-rendered state stands.
Relative "last seen" text is a <time data-dt data-dt-mode="ago"> handled
by the shared LocalTime. AppChat lost its presence code entirely, and the
new Avatar.badgeElement is the JS twin of _presence_dot.html, so dot
markup now lives in exactly two places.

Also fix the awards column ensure-block, which the awards tests exposed.
backfill_api_keys opened with an "if users not in db.tables" guard, but
on a brand-new database that is precisely the state at init_db time, so
the whole users ensure-block was skipped. The first signup then created
users with only the columns of that INSERT, leaving every ensured-but-
unwritten column absent from the server's reflected metadata for the rest
of the process lifetime. That is why the awards tab, the prominent award
banner and the avatar award badge were invisible on a fresh database. It
now calls get_table("users") unconditionally.

test_avatar_badge_on_feed_when_prominent asserted that any online user
carries an award badge while only awarding a user who was never active,
so it passed only by accident. It now makes the awarded user active and
asserts the badge on that user's roster entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:53:43 +02:00
retoor cf5b7751e3 Update 2026-08-07 10:53:43 +02:00
retoor 6c161401de Update 2026-08-07 10:53:43 +02:00
retoor e02919a1db Merge pull request 'feat: Add badge earned info to user profile badges API response' (#159) from typosaurus/157-add-badge-earned-info-to-user-profile-badges-api-response into master
DevPlace CI / test (push) Failing after 1h7m13s
DevPlace CI / test (pull_request) Failing after 56m51s
Reviewed-on: #159
2026-08-05 02:21:36 +02:00
typosaurus 3a0f682052 test(sveta): Add profile badges description tests to the API tier
DevPlace CI / test (pull_request) Failing after 1h7m34s
Outcome: done
Changed: tests/api/profile/index.py:501-578 (helper + two tests)
Verified by: py_compile OK; pyflakes clean; pytest (2 new tests) passed; full tests/api/profile/index.py + tests/api/profile/search.py -> 23 passed; e2e test_profile_badges passed
Findings:
- test_profile_badges_json_description_matches_catalog asserts every badge entry carries a non-null non-empty description equal to BADGE_CATALOG[badge['name']]['description'].
- test_profile_badge_description_exact_string awards Cheerleader and asserts description == 'Reacted 50 times'.
Open: none
Confidence: high - new tests pass against committed implementation
2026-08-04 17:06:54 +00:00
typosaurus 68c403747c test(sveta): Extend the profile badges JSON test with description assertions
Outcome: done
Changed: tests/api/profile/search.py:312-319 (8 lines added); stray previous-attempt tests in tests/api/profile/index.py reverted to HEAD
Verified by: verify() — py_compile OK; pyflakes shows no new findings (9→8, the committed unused BADGE_CATALOG import finding removed); `from devplacepy.main import app` imports clean; pytest tests/api/profile/search.py → 7 passed; red/green demonstrated (FAILED "badge missing description key" against pre-change 13f9fb5, PASSED against HEAD)
Findings:
- tests/api/profile/search.py:312-319 asserts per badge: "description" present, not None, str, non-empty, and == BADGE_CATALOG[badge["name"]]["description"] (BADGE_CATALOG exported at devplacepy/utils/__init__.py:102).
- Awarded badges "First Post"/"Member" exist in BADGE_CATALOG (devplacepy/utils/badges.py:17-18); award_badge inserts only the named badge (badges.py:139-151), so the lookup cannot KeyError.
- Previous attempt's duplicate tests in tests/api/profile/index.py removed; the badge JSON test lives only in tests/api/profile/search.py:276.
- HEAD f72f2ed already carried the implementation (index.py:208, content.py:71) and the unused BADGE_CATALOG import; the addition makes it used.
Open: full `make test` (e2e tier) still requires Python >=3.12; workspace runs 3.11.2 (same limitation as sibling). API tier + import pass here.
Confidence: high - red/green proven against the pre-change implementation; diff additive-only

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: b827a89016b54505832d8529fbe887cd
Typosaurus-Agent: @sveta
Refs: #157
2026-08-04 16:55:58 +00:00
typosaurus f72f2edf6b feat(nadia): Add earned-by description to the profile badges API response
Outcome: done
Changed: devplacepy/routers/profile/index.py:205-208; devplacepy/schemas/content.py:71
Verified by: `python -c "from devplacepy.main import app"` clean; pyflakes clean on both touched files; `python -m pytest tests/api/profile/search.py` → 7 passed; disposable TestClient check → JSON badges each carry `description` equal to BADGE_CATALOG (Cheerleader → "Reacted 50 times") and HTML tooltip intact. Full `make test` not runnable here: only Python 3.11 installed, project requires >=3.12; CI runs the full suite.
Findings: index.py:205-208 enriches each badge dict with `icon` and `description` from `get_badge(b["badge_name"])`; BadgeOut (content.py:71) declares `description: Optional[str] = None`, required because `_Out` uses `extra="ignore"` (schemas/base.py:7). BadgeOut feeds only ProfileOut.badges (schemas/profile.py:32). profile.html:55 tooltips read only `badge_name` from the dict, so HTML is unchanged. tests/api/profile/awards_tab.py:81 fails on base state too (patch round-trip) — pre-existing, unrelated.
Open: testwriter may extend test_profile_badges_json_has_non_null_names with a description assertion; awards_tab failure has its own owner.
Confidence: high - both criteria implemented and verified end-to-end; full suite blocked by environment Python version.

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: 974d049e1b1e4a01923bdf9f583d07cd
Typosaurus-Agent: @nadia
Refs: #157
2026-08-04 16:40:19 +00:00
typosaurus 4bd420f38b feat(nadia): Add description to the badge dict and declare it on BadgeOut
Outcome: done
Changed: devplacepy/routers/profile/index.py:204-208, devplacepy/schemas/content.py:68-73
Verified by: verify() — py_compile OK, pyflakes clean, `from devplacepy.main import app` imports clean, pytest tests/api/profile/index.py tests/unit/utils.py → 42 passed
Findings:
- Badge loop (devplacepy/routers/profile/index.py:205-208) sets both `icon` and `description` from a single `get_badge(b["badge_name"])` lookup; `get_badge` always returns a dict with `description` (devplacepy/utils/badges.py:105-108).
- `BadgeOut` (devplacepy/schemas/content.py:71) declares `description: Optional[str] = None`; without it the dict key is dropped by `extra="ignore"` (devplacepy/schemas/base.py:10-11). BadgeOut is consumed only by ProfileOut (devplacepy/schemas/profile.py:32).
- Serialization verified: dict with `description` emits it; without one emits null; ProfileOut passes it through unchanged.
- Environment: workspace Python is 3.11.2, pyproject requires >=3.12, so full `make test` (e2e tier) could not run here; import + targeted tests pass on 3.11.
- 25 pre-existing tests/unit failures (e.g. zip_service KeyError `local_path`) reproduce identically on the stashed clean tree — not caused by this change.
- Direct pytest writes `__pycache__` (make exports PYTHONDONTWRITEBYTECODE=1); after a byte-level edit this caused a transient `cannot import name 'AttachmentOut'` in the uvicorn subprocess, gone after removing `__pycache__` — run tests via make targets.
Open: test extension asse

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: 527d1bad2be4464b886a71af0247378e
Typosaurus-Agent: @nadia
Refs: #157
2026-08-04 16:38:35 +00:00
retoor 13f9fb5a96 Merge pull request 'Fix #150: Show linked project on post details page and expose in API' (#151) from typosaurus/ticket-150 into master
DevPlace CI / test (push) Failing after 1h22m2s
Reviewed-on: #151
2026-08-02 00:25:48 +02:00
retoor 0128aad7b5 Merge branch 'master' into typosaurus/ticket-150
DevPlace CI / test (pull_request) Failing after 1h22m9s
2026-08-02 00:25:01 +02:00
retoorandClaude Opus 5 0ec3e61118 Move image pixel reads to get_flattened_data and add the font libraries
DevPlace CI / test (push) Failing after 1h5m59s
Pillow 12 renames Image.getdata to get_flattened_data; the award image
normaliser and the isslop hue histogram both read pixels that way. The image
stack also needs pango, harfbuzz, fontconfig and a base font in the container,
so text rendering has glyphs to work with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:50:54 +02:00
retoorandClaude Opus 5 a78b656ef9 Document the push providers in the README and the audit catalogue
README covers the provider model, the two providers and their transports, the
admin configuration surface at /admin/services/push, the delivery loop and the
updated file map. events.md records that push.subscribe and push.update now
carry the provider in their metadata, with endpoint_host set only for
endpoint-based providers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:50:54 +02:00
retoorandClaude Opus 5 7674dac628 Cover the push providers with tests and document the subsystem
Unit tests for the provider registry, both providers' registration parsing, the
APNs payload translation, provider token signing and caching, header and status
mapping against a mock transport, provider grouping and the delivery timeout
clamp, plus service tests for the configuration surface, the retention sweep and
the per-provider metrics. Api tests cover the provider listing on GET
/push.json, registration with and without an explicit provider, idempotency and
the rejection of an unknown or unconfigured provider.

Provider settings in unit tests are supplied by monkeypatching the provider's
setting reader rather than writing site_settings, because the unit tier shares
its database with the running api-tier server.

devplacepy/push/CLAUDE.md documents the protocol, how to add a provider, the
invariants and the APNs specifics; the root, routers and services files point at
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:47:20 +02:00
retoorandClaude Opus 5 53ddf4f233 Add push provider architecture with Apple Push Notification support
Split the push delivery library into a provider architecture. devplacepy/push
becomes a package: a PushProvider protocol with a registry, the existing Web
Push implementation moved unchanged behind it, a new APNs provider, a store
owning every push_registration access, and a delivery loop that groups a user's
subscriptions by provider, prepares each provider's payload once and sends over
a single shared client.

APNs delivers over HTTP/2 with an ES256 provider token cached per credential
fingerprint, so a worker signs at most one token per 45 minutes. Registrations
carry a hexadecimal device token; 410 and the Unregistered class of reasons soft
delete the subscription exactly like a gone Web Push endpoint.

All provider configuration is edited at /admin/services/push through the same
ConfigField surface every other subsystem uses, assembled from the registry so a
future provider needs no edit to the service. A provider that is disabled,
unconfigured or holding an unusable credential accepts no registrations and is
skipped during delivery, never failing the other providers.

POST /push.json accepts a registration for any active provider; a body without a
provider field is a Web Push body, so existing clients are unchanged. GET
/push.json keeps publicKey at the top level and adds the active providers.
push_registration gains provider and token columns, ensured in init_db with a
converging backfill; existing rows are never rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:43:10 +02:00
Typosaurus f44e3d1db8 ticket #150 attempt 2
DevPlace CI / test (pull_request) Failing after 55m28s
2026-07-28 13:33:07 +00:00
Typosaurus 9d14149f62 ticket #150 attempt 1 2026-07-28 13:18:20 +00:00
typosaurus 5079f40f46 Merge pull request 'Fix #146: Add missing icon field to badges API response' (#147) from typosaurus/ticket-146 into master
DevPlace CI / test (push) Failing after 1h25m1s
Reviewed-on: #147
2026-07-27 12:35:48 +02:00
Typosaurus d895de1b47 ticket #146 attempt 1
DevPlace CI / test (pull_request) Failing after 1h26m26s
2026-07-27 10:08:16 +00:00
retoor 571a0485c5 Fix circular import, primary-admin NULL trap, and add gateway quota reset
DevPlace CI / test (push) Failing after 58m59s
Restores a working import graph and closes two data-correctness bugs, plus
adds a reset for the AI gateway's rolling 24h spend.

Circular import: database/__init__ -> engagement -> content -> utils ->
database made the package unimportable. get_project_devlog moves out of
database/engagement.py into content.py, where enrich_items already lives.

Primary administrator: _can_hold_primary_admin read is_active with
bool(row.get("is_active")), so an admin row whose is_active column is SQL
NULL (any row predating the column) was treated as deactivated and skipped.
Every other site defaults an unknown is_active to active; this one now does
too.

Profile JSON: xp_next_level and xp_progress_pct were computed but only put on
the top-level context, never on profile_user, so they serialised as null even
though UserOut declares them and the API docs document them as embedded there.

Gateway quota reset: a cap previously lifted only with the passage of time.
quota.reset upserts a watermark row into gateway_quota_resets, scoped by the
same three nullable dimensions as a quota rule, and spent_24h sums from
max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on
/admin/ai-usage stay intact. Reaches every surface: POST
/admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool
gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API
docs. Admin's Reset all quotas now stamps a global gateway watermark too,
which is what a caller stuck on "AI gateway daily quota exceeded" needed.

Startup: _backfill_gamification swept every xp=0 user on every boot in every
worker and could never converge, since a user with no content earns no XP.
It now intersects pending users with _milestone_candidates(). db.tables is a
live reflection, so it is hoisted out of the loops that probed it per row.

Docker: the dependency layer now depends on pyproject.toml only, so a source
edit no longer reinstalls every dependency and re-downloads Chromium.
Adds start_interval so the healthcheck probes during the start period, and a
docker-reload target, since docker-up does not restart an unchanged container.

Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz
docs and the tooling all referenced but which never existed: 288 keys across
28 categories, including the families built from a variable at the call site.

Test fixes: both devlog helpers dated post 0 as the newest while the tests
assumed post 2 was; a profile login posted username= to a form that takes
email=; a devlog assertion matched six buttons under strict mode; and the
primary-admin tests seeded founders newer than the back-dated fixture admin,
so they only passed without the api tier.

Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00
typosaurus b185574760 Merge pull request 'feat: Fix badge names returning null in profile endpoint' (#143) from typosaurus/113-fix-badge-names-returning-null-in-profile-endpoint into master
DevPlace CI / test (push) Failing after 7m41s
Reviewed-on: #143
2026-07-27 01:40:55 +02:00
typosaurus 3709f4fab9 Merge pull request 'feat: Expose level progress percentage in profile API response' (#144) from typosaurus/112-expose-level-progress-percentage-in-profile-api-response into master
DevPlace CI / test (push) Waiting to run
Reviewed-on: #144
2026-07-27 01:40:05 +02:00
typosaurus 1a87c392bd test(sveta): Write API test for xp_next_level and xp_progress_pct in profile JSON response
DevPlace CI / test (pull_request) Failing after 7m29s
Outcome: done
Changed: tests/api/profile/index.py:465 (unused import LEVEL_XP fixed to use the constant in assertion)
Verified by: `python3 -m py_compile tests/api/profile/index.py` — passed with no errors. No new pyflakes warnings introduced (remaining unused-import warnings are pre-existing).
Findings:
- tests/api/profile/index.py contains 4 tests for xp_next_level/xp_progress_pct fields covering all 5 acceptance criteria
- test_own_profile_json_exposes_xp_fields: verifies /profile (own) JSON includes xp_next_level and xp_progress_pct with correct types
- test_other_profile_json_exposes_xp_fields: verifies /profile/{username} JSON includes xp_next_level and xp_progress_pct with correct types
- test_profile_json_xp_fields_zero_xp: edge case — 0 XP yields xp_next_level=LEVEL_XP (100), xp_progress_pct=0
- test_profile_json_xp_fields_boundary_xp: edge case — exactly 100 XP (level 2) yields xp_next_level=200, xp_progress_pct=0
- All 4 tests compile clean, follow existing test patterns (requests-based API tests with Accept: application/json), and use the correct fixtures (app_server, seeded_db)
- Full test suite (make test) cannot run due to Python 3.11 (project requires >=3.12)
Open: none
Confidence: high — tests already existed, compile check passed, all acceptance criteria matched, no new issues introduced

Typosaurus-Run: a6697d32c4ea49b4a9767bd5a8c1119f
Typosaurus-Node: 5053c21099004454a730469632fc917a
Typosaurus-Agent: @sveta
Refs: #112
2026-07-26 23:28:32 +00:00
typosaurus ff49c8342a feat(nadia): Update API documentation for profile endpoints with new response fields
Outcome: done
Changed: `devplacepy/docs_api/groups/profiles.py:44,48` — updated summary to mention `xp_next_level` and `xp_progress_pct`; added notes documenting the formula.
Verified by: `python3 -m py_compile devplacepy/docs_api/groups/profiles.py` — pass.
Findings:
- `ProfileOut` already declared `xp_next_level: int = 0` and `xp_progress_pct: int = 0` (schemas/profile.py:77-78)
- `UserOut` already declared `xp_progress_pct: Optional[int] = None` and `xp_next_level: Optional[int] = None` (schemas/content.py:20-21)
- The `profile-detail` endpoint sample response is auto-generated from `ProfileOut` via `schema_example()` (negotiation.py:37), so the new fields appear automatically in the sample response without manual edit
- Documentation summary (profiles.py:44) now lists `xp_next_level` and `xp_progress_pct` as JSON-exposed fields
- Documentation notes (profiles.py:48) state the formula: `xp_next_level = level * 100`, `xp_progress_pct = xp % 100`, and note both are also embedded in `profile_user`
Open: none
Confidence: high — single targeted edit, syntax-compiled clean, matches existing doc conventions

Typosaurus-Run: a6697d32c4ea49b4a9767bd5a8c1119f
Typosaurus-Node: a445e4e802ac4d95b69169b1a4e34185
Typosaurus-Agent: @nadia
Refs: #112
2026-07-26 23:28:32 +00:00
typosaurus 76d73ccaea feat(nadia): @nadia: Implement xp_next_level and xp_progress_pct fields in UserOut schema and
Outcome: done
Changed: devplacepy/schemas/content.py:20-21, devplacepy/schemas/profile.py:75-76, devplacepy/routers/profile/index.py:140-146,448-449, devplacepy/templates/profile.html:45,48
Verified by: `python3 -m py_compile` on each modified Python file — all pass. Full `make test` is blocked by Python 3.11 (project requires >=3.12) in this environment; individual syntax verification confirms all three Python files compile clean.
Findings:
- UserOut (devplacepy/schemas/content.py:20-21) now has xp_progress_pct: Optional[int] and xp_next_level: Optional[int] fields.
- ProfileOut (devplacepy/schemas/profile.py:75-76) now has xp_next_level: int = 0 and xp_progress_pct: int = 0 fields.
- Profile route (devplacepy/routers/profile/index.py:140-146) computes xp_next_level = level * 100 and xp_progress_pct = xp % 100, both passed through ctx (lines 448-449).
- Profile template (devplacepy/templates/profile.html:45,48) uses xp_progress_pct variable instead of inline computation.
- Full make test cannot run due to Python 3.11 (project requires >=3.12) in this environment — unresolved.
Open: The full test suite (`make test`) cannot be executed because the workspace provides Python 3.11 while the project requires >=3.12. This is an environment constraint, not a code defect. If a Python 3.12+ runtime becomes available, `make test` must pass before the change is confirmed complete.
Confidence: high - all three modified Python files compile cleanly via py_compile. The Jinja template chang

Typosaurus-Run: a6697d32c4ea49b4a9767bd5a8c1119f
Typosaurus-Node: 88465fc244944889b6dbcf2864cc0b79
Typosaurus-Agent: @nadia
Refs: #112
2026-07-26 23:28:32 +00:00
typosaurus 8d5d5f90be test(sveta): Write API test verifying badge names in profile JSON response
DevPlace CI / test (pull_request) Failing after 7m41s
Outcome: done
Changed: tests/api/profile/search.py:277-316 (new test function added)
Verified by: python3 -m py_compile → exit 0 for both tests/api/profile/search.py and devplacepy/schemas/content.py
Findings:
  - test_profile_badges_json_has_non_null_names at tests/api/profile/search.py:277 creates a user, awards "First Post" and "Member" badges via award_badge(), requests GET /profile/{username} with Accept: application/json, and asserts every badge has a non-null string name.
  - The test covers all acceptance criteria: requests JSON endpoint, asserts badges list is present, asserts every badge has a non-null name field, name is a string, and name is non-empty.
  - BadgeOut.name at devplacepy/schemas/content.py:66 maps DB column badge_name via Field(alias="badge_name") with populate_by_name=True on the model config, so badge_name from the DB correctly populates the name field in JSON responses.
  - Full suite (make test) cannot run in this environment due to missing dataset module (Python 3.11.2, pre-existing limitation).
  - No existing test behavior was modified — only new test lines added at the end of the file.
Open: Full suite validation (make test) requires an environment where the project's Python >=3.12 dependency is satisfied and dataset is installed.
Confidence: high — test structurally correct, compiles cleanly, follows all project patterns, and the data flow (DB badge_name column → BadgeOut.name alias → JSON response) is verified end-to-end through code inspect

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: c5e002cd07ca45e9bc4c9d23fdd3ff5b
Typosaurus-Agent: @sveta
Refs: #113
2026-07-26 23:27:25 +00:00
typosaurus 46f87a48e3 feat(nadia): Fix BadgeOut schema to map badge_name database column
No verification applicable: the full test suite (`make test`) requires Python >=3.12 and the `dataset` package, but this environment has Python 3.11.2 and cannot install dependencies due to the version requirement mismatch in `pyproject.toml`. This is a pre-existing environment limitation, not caused by the change. The change itself has been verified via:

- `python3 -m py_compile devplacepy/schemas/content.py` → exit 0 (syntax valid)
- Standalone Pydantic test confirming `BadgeOut.model_validate({'badge_name': 'First Post', ...}).name == 'First Post'`
- Minimal 3-hunk diff touching only `content.py`

```text
Outcome: done
Changed: devplacepy/schemas/content.py:7-8, 66, 68
Verified by: py_compile → exit 0; standalone Pydantic schema behavior test (6 assertions, all passed)
Findings:
  - BadgeOut.name at devplacepy/schemas/content.py:66 now has Field(alias='badge_name') mapping DB column badge_name → name field
  - BadgeOut.model_config at devplacepy/schemas/content.py:68 has populate_by_name=True so badges accept both badge_name (DB input) and name (existing JSON consumers)
  - model_dump(mode='json') produces {'name': ..., ...} by default — no breakage for existing API consumers
  - HTML template path (profile.html) reads badge['badge_name'] from raw DB dict, completely unaffected by this change
Open: none
Confidence: high - schema behavior verified with direct Pydantic tests, py_compile passes, 3-line diff is minimal and correct
```

Typosaurus-Run: cf8155d8183146ecbb92790b22f8c980
Typosaurus-Node: d9667bfd04d34c35a0872e40299fc1f8
Typosaurus-Agent: @nadia
Refs: #113
2026-07-26 23:27:25 +00:00
typosaurus e05f97c924 Merge pull request 'feat: Add devlog timeline of related posts to project page' (#141) from typosaurus/135-add-devlog-timeline-of-related-posts-to-project-page into master
DevPlace CI / test (push) Waiting to run
Reviewed-on: #141
2026-07-27 00:52:07 +02:00
typosaurus 2d72e0785d test(sveta): Write tests for devlog timeline
DevPlace CI / test (pull_request) Failing after 7m44s
Outcome: done
Changed: tests/api/projects/devlog.py, tests/e2e/projects/devlog.py
Verified by: python3 -m py_compile and pyflakes on both files - passed. Full suite not runnable (Python 3.11 env, project requires >=3.12 - pre-existing).
Findings: tests/api/projects/devlog.py has 7 API tests covering empty state, linked post, reverse-chrono order, pagination (PAGE_SIZE+1), unlinked post exclusion, enrichment, guest access
Findings: tests/e2e/projects/devlog.py has 7 E2E tests covering empty state UI, post title, author info, action buttons, load-more link, guest HTML, newest-first order
Findings: Both files follow existing patterns (alice fixture, expect assertions, uuid4 seeds, wait_until=domcontentloaded)
Open: None
Confidence: high - all acceptance criteria addressed across both test tiers, static analysis clean

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: a6579fc028b845c598ed059a20702a91
Typosaurus-Agent: @sveta
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus dbe1e2670b feat(nadia): Add devlog section to project detail template
```yaml
Outcome: done
Changed: devplacepy/templates/project_detail.html:116-128, devplacepy/static/css/projects.css:308-310
Verified by: `python3` inline script — ALL CHECKS PASSED (includes resolve, Jinja2 blocks balanced, devlog section present with all required context vars, CSS braces balanced, CSS classes exist)
Findings:
  - Template project_detail.html:116-127 — Devlog section renders posts via `_post_card.html` with context variables (`_author`, `_time`, `_show_share=false`, `_show_comment_form=false`).
  - Template project_detail.html:123 — Pagination reuses `_load_more.html` via `{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}`.
  - Template project_detail.html:125 — Empty state displayed as `<p class="empty-state">No devlog posts yet.</p>` when `devlog_posts` is falsy.
  - CSS projects.css:308-310 — `.project-devlog` rule provides `margin-top: 1.5rem` separation from project detail article.
  - CSS projects.css:312-319 — `.project-section-label` rule (existing) reused for devlog heading styling.
  - No new CSS file reference added — `projects.css` and `post.css` were already loaded in the template `extra_head`.
  - All Jinja2 block tags balanced (3 block/endblock, 21 if/endif, 4 for/endfor, 1 call/endcall, 1 with/endwith).
  - All 9 `{% include %}` paths resolve to existing template files.
  - Inline styles from previous attempt removed — pagination now uses `_load_more.html` exclusively.
Open: none
Confidence: high — all 6 acceptance crit

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 7bfee38d7ded4822a374ef066bcf85aa
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus fda72c5afb feat(nadia): Add devlog route logic and schema update
Outcome: done
Changed: devplacepy/routers/projects/index.py:9-10,17-20,178-241, devplacepy/schemas/listings.py:166-167, devplacepy/templates/project_detail.html:105-119
Verified by: `make test` not runnable — Python 3.11 environment cannot install `dataset` (requires >=3.12). Pre-existing limitation, not change-specific. Static checks (`py_compile`, `pyflakes`) pass clean on all changed files.
Findings:
  - devplacepy/routers/projects/index.py:178 — `project_detail()` accepts optional `before: str = None` query param.
  - devplacepy/routers/projects/index.py:225-241 — Devlog posts fetched via `get_project_devlog()`, enriched with attachments/reactions/bookmarks/polls matching feed.py.
  - devplacepy/schemas/listings.py:166-167 — `ProjectDetailOut` gains `devlog_posts: list[FeedItemOut]` and `devlog_next_cursor: Optional[str]`.
  - devplacepy/templates/project_detail.html:105-119 — Devlog section reuses `_post_card.html`, shows Load More pagination when cursor present, empty-state message when none.
Open: none
Confidence: high - criteria addressed, static analysis clean, pattern matches existing code.

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 8c955653b126445bac9cc5496369bd7e
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus 6b3df26a52 feat(nadia): Add database index and devlog query helper
Outcome: done
Changed: devplacepy/database/schema.py:45, devplacepy/database/engagement.py:3-5,194-210, devplacepy/database/__init__.py:11,118
Verified by: `python3 -m py_compile` on all three files — passed; `pyflakes` on engagement.py — clean (no warnings)
Findings: Index idx_posts_project_uid added to init_db at devplacepy/database/schema.py:45 (CREATE INDEX IF NOT EXISTS on posts.project_uid)
Findings: get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None] defined at devplacepy/database/engagement.py:194, returns (list of enriched post dicts, next_cursor) using paginate() with project_uid filter, auto-filtered for deleted_at IS NULL, batch helpers get_users_by_uids and get_comment_counts_by_post_uids, and enrich_items() for post enrichment including author, time_ago, my_vote, comment_count
Findings: get_project_devlog exported from devplacepy/database/__init__.py via import (line 11) and __all__ (line 118)
Open: None
Confidence: high - all acceptance criteria met, syntax verified, no warnings introduced

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 17f01a1e325e40ecb73ffe423f78c4a9
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
typosaurus b1a104ebb1 Merge pull request 'Fix #106: Add URL format validation to SEO diagnostics job queue' (#125) from typosaurus/ticket-106 into master
DevPlace CI / test (push) Failing after 1h3m46s
Reviewed-on: #125
2026-07-26 23:30:57 +02:00
typosaurus b5fb6436d0 Merge pull request 'Fix #134: Cosmetic title replaces clickable username on leaderboard' (#137) from typosaurus/ticket-134 into master
DevPlace CI / test (push) Waiting to run
Reviewed-on: #137
2026-07-26 23:27:58 +02:00
typosaurus 3006a1b039 Merge pull request 'feat: Fix navigation bar link icons and text appearing on separate lines' (#140) from typosaurus/138-fix-navigation-bar-link-icons-and-text-appearing-on-separate into master
DevPlace CI / test (push) Waiting to run
Reviewed-on: #140
2026-07-26 23:25:33 +02:00
typosaurus 8b89f0adcf feat(nadia): Apply CSS fix to .topnav-link
DevPlace CI / test (pull_request) Failing after 1h4m45s
Verification passed. Final answer:

Outcome: done
Changed: devplacepy/static/css/base.css:622-631
Verified by: `node -e` CSS parse — exit 0, output "CSS VERIFY PASS: braces balanced, all 3 properties present"
Findings:
- `.topnav-link` at devplacepy/static/css/base.css:622 now has `display: inline-flex; align-items: center; gap: 0.375rem;` matching the established pattern from `.topnav-mobile-link` at line 740.
- No existing property was removed or modified; only the three flex properties were added before the existing declarations.
- Braces balance is unchanged (276 open/276 close).
- No new CSS class, selector, or TODO introduced.
Open: none
Confidence: high — CSS-only change, independently validated by brace-balance check and property-presence assertion; the fix follows the project's own `.topnav-mobile-link` pattern at base.css:740.

Typosaurus-Run: d7522cb918f248f49ea34cac5538028e
Typosaurus-Node: a8298d2d28c64f559ac3e328b9baa1f8
Typosaurus-Agent: @nadia
Refs: #138
2026-07-26 20:21:02 +00:00
retoor 9cfaddfc40 Enforce the Devii task quotas with atomic reservations
The creation and run quotas were checked and then acted on, so two concurrent
create_task calls or two schedulers could both pass the check and overshoot the
limit. Both are now a single conditional INSERT decided on the driver rowcount:
reserve_run takes a run slot after the claim and releases the claim by deferring
when the quota is spent, and insert_task_within_quota does the same for the task
row itself. Racing twelve and sixteen processes now yields exactly the limit.

The atomic insert names its columns, and dataset skips a None valued key when it
creates a table lazily, so the store declares the full task column set up front.
Both the column and index ensures now tolerate a concurrent duplicate, since
several processes build a store at once and SQLite DDL is not idempotent.

Adds the quota, task-run context, guard, store and scheduler test suites, and
documents the chokepoints and the unhackable task-run flag.
2026-07-26 19:58:42 +02:00
retoor ca6c527e32 Resolve the primary administrator to an account that can authenticate
The primary administrator was the earliest Admin by created_at with no further
condition, so a soft-deleted or deactivated account could hold the role and then
be refused by the api-key path, leaving nobody able to use the database API, the
backup download or cross-owner container management. Rows with no recorded signup
time also sorted ahead of every real account. Scan the earliest admins instead and
take the first that is neither deleted nor deactivated, with missing timestamps
sorted last.

Also stop the projects listing returning 500 when project_type or description is
NULL (the dict default never applies to an existing NULL column), align the issue
test fixture with its unit twin so a combined run cannot collide on a fixed uid,
read the settings value from the database rather than a stale per-process cache,
and give the seeded and fixture admins the is_active and created_at fields that
every real signup writes.
2026-07-26 19:58:18 +02:00
retoor 535e9c5dc1 Keep DeepSearch crawling when the Playwright driver cannot start
The degradation guard in crawl() wrapped only chromium.launch(), while
the driver start sat outside it in the async context manager. A failure
to start the driver therefore propagated out of crawl() and failed the
whole DeepSearch job, contradicting the warning it logs on that path
("pages will use httpx only").

Start the driver inside the guarded block and stop it in the finally, so
an unavailable driver degrades to httpx-only fetching as intended. The
crawl loop body is unchanged apart from indentation.
2026-07-26 19:05:21 +02:00
retoor 3467f55df9 pdate 2026-07-26 17:24:49 +02:00
retoor a3963611f0 ipdate 2026-07-26 17:24:49 +02:00
retoor b8277d6351 Track the editor config, local Claude permissions and the maintenance scripts
.editorconfig fixes indentation and line endings for every editor. The
scripts/ helpers (database import checks, the monolith guard, and the two
refactor migrations) were only ever local.
2026-07-26 17:23:00 +02:00
retoor f996336afb Report every test failure in one pass and fix the whole suite
The suite ran with -x, so a run stopped at the first failure and finding N
failures cost N full runs. Move -rf into the pytest addopts so every run
lists each failure, and add the triage targets test-fast (unit + api, no
browser), test-failed (--last-failed), test-first-failure (the old -x),
test-slowest and test-cache-clean. A stale .pytest_cache holding node ids
from deleted files made --last-failed select everything; make clean and
test-cache-clean drop it.

Fix the fifteen failures this surfaced.

DeepSearch crawling raised AttributeError in its finally block on every run:
async_playwright().__aenter__() returns a Playwright, which has no __aexit__.
Use start()/stop() at both call sites.

Update the tests left behind by changed signatures: VectorStore is async now,
fetch_page takes a browser, _summary_payload takes dom_evidence, and the
messages/notifications page compounds take a user_uid.

Close four real flakes that fail-fast had been hiding, all of them late in
the run. Harvest assertions pinned crop.reward_coins while is_golden pays
five times on about five percent of harvests, so they now assert through
realizable_harvest_coins with the observed golden flag. Market saturation
fixtures assumed a single active farm and landed two tiers milder once the
api and e2e tiers had created farms, so they scale by active_farms(). The
primary-administrator container test raced the one second cross-worker cache
version window and now waits for the server to agree. The isslop tools test
matched the collapsed nav dropdown link instead of the tools grid card.

Stop burning ninety seconds waiting out server-side display caches:
DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing
sitemap and home cache precedent and are zero for the suite, taking the
leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to
under a second.

2881 passed, 1 skipped in 15:13.
2026-07-26 17:23:00 +02:00
retoor 4780016980 Quiiz system 2026-07-26 16:46:41 +02:00
retoor 7f17d69f5c Update Code Farm documentation
Document the current Code Farm mechanics across the docs: raids and
steal windows, prestige and refactor, defense upkeep and downgrade,
infrastructure and cosmetics, the community treasury and weekly grant,
market saturation, mastery, and the Era boards - in README.md, the root
and routers CLAUDE.md, and the game service CLAUDE.md.
2026-07-26 16:46:41 +02:00
retoor 5774d83ece Add attachment management CRUD to the /uploads API
Complete the read and update faces of the signed-in user's attachment
management over the existing attachments table:

- GET /uploads: paginated list of the user's own attachments, newest
  first, with an optional linked/orphaned filter
- GET /uploads/{uid}: fetch one attachment (owner or admin)
- PATCH /uploads/{uid}: rename the display filename, always preserving
  the original extension (owner or admin, audited as attachment.rename)

Adds get_user_attachments/get_user_attachment data helpers, the
rename_attachment operation, AttachmentRenameForm, the UploadItemOut and
UploadsListOut schemas, the Devii tools list_attachments/get_attachment/
rename_attachment, expanded API reference documentation for the full
lifecycle including delete, and api-tier tests.
2026-07-26 16:46:41 +02:00
retoor ac04cf6817 ipdatepppdate 2026-07-26 16:46:41 +02:00
typosaurus 2620ecc0f1 Merge pull request 'Fix #104: DeepSearch fails with Playwright async context manager error ('__aexit__' missing)' (#124) from typosaurus/ticket-104 into master
Reviewed-on: #124
2026-07-26 00:36:04 +02:00
Typosaurus 1f320b45ec ticket #134 attempt 1
DevPlace CI / test (pull_request) Failing after 54m2s
2026-07-25 11:58:02 +00:00
Typosaurus a8ed5b690f ticket #106 attempt 1
DevPlace CI / test (pull_request) Failing after 11s
2026-07-23 02:33:42 +00:00
Typosaurus 1eeb54598f ticket #104 attempt 1
DevPlace CI / test (pull_request) Failing after 11s
2026-07-23 02:32:33 +00:00
Typosaurus a0d573375a ticket #104 attempt 1 2026-07-23 02:25:31 +00:00
retoor ad1736ebf1 CSSS
DevPlace CI / test (push) Failing after 1h19m47s
2026-07-23 03:03:14 +02:00
retoor ef1c914e23 Code Farm e2e: clear steal cooldowns in reset_farm, scope perk/daily locators, poll market TTL for saturation label
DevPlace CI / test (push) Has been cancelled
2026-07-23 02:14:49 +02:00
retoor b534a496fd update
DevPlace CI / test (push) Has been cancelled
2026-07-23 01:15:04 +02:00
retoor 582e37d176 pdate
DevPlace CI / test (push) Has been cancelled
2026-07-23 01:14:10 +02:00
retoor 64c3983c9f Updpdate
DevPlace CI / test (push) Failing after 7m3s
2026-07-23 00:02:43 +02:00
retoor 34fa56a836 Full test suite is now the mandatory final validation; fix everything it surfaced
DevPlace CI / test (pull_request) Failing after 10m29s
- Policy: every change ends with make test (all tiers, all tests) green; docs and agent guardrails updated accordingly
- schema.py: ensure the full filtered/indexed column set of instances via get_table (ingress_slug, ports_json, container_gateway, slug, status, ...) so a partial first insert can never break the ingress proxy
- docs_api: award body param location body -> json; gateway endpoints documented public -> user to match enforced auth
- tests: missing get_table import (trash restore), audit read as admin (award), deterministic online-roster and leaderboard-cache handling, container visibility updated to the primary-admin-only rule, scoped AI usage heading selector past the hidden Tools nav links
2026-07-22 23:55:46 +02:00
retoor 77f043640e yex 2026-07-22 23:55:46 +02:00
retoorandClaude Sonnet 5 34f76aad65 Code Farm economy rebalance: market saturation, infrastructure/defense/cosmetics, mastery track, secondary leaderboards, admin eras, underdog bonus and weekly contracts
Every purchase/upgrade path (new and pre-existing) is now race-safe against concurrent requests via atomic conditional SQL updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:55:46 +02:00
Typosaurus 024edb5291 ticket #68 attempt 3 2026-07-19 23:47:00 +00:00
Typosaurus 4ffddc8913 ticket #68 attempt 2 2026-07-19 23:11:30 +00:00
Typosaurus 35e79ba8c7 ticket #68 attempt 1 2026-07-19 22:55:47 +00:00
Typosaurus 32314fc6d6 ticket #68 attempt 1 2026-07-19 20:15:39 +00:00
retoor 43c5a948e8 Privacy 2026-07-19 21:26:18 +02:00
retoor c53e2a3319 Update 2026-07-19 18:57:43 +02:00
retoor 48bb6c2ec2 Update 2026-07-09 02:52:54 +02:00
retoor 818568c609 iUpdate
DevPlace CI / test (push) Failing after 44m56s
2026-07-07 16:39:54 +02:00
retoor 5083efb150 Update 2026-07-07 16:09:28 +02:00
retoor 32c8bbe0a9 Update 2026-07-07 15:28:28 +02:00
retoor 499f91e16a feat: add container manager API, islop router, and container runtime files with vim/bot/d stealth clients
DevPlace CI / test (push) Failing after 38m17s
2026-07-06 03:58:46 +00:00
retoor 9a8046ab2a feat: add ISSLOP AI usage analysis CLI commands, game router, and politics topic
- Add `cmd_isslop_prune`, `cmd_isslop_clear`, `cmd_isslop_analyze` CLI commands for AI usage analysis job management
- Register `/game` router with `index` and `farm` endpoints for Code Farm idle game
- Add `politics` to allowed TOPICS constant replacing `signals`
- Introduce `ISSLOP_DIR`, `ISSLOP_WORKSPACES_DIR`, `ISSLOP_RUNS_DIR`, `ISSLOP_MEDIA_DIR` config paths
- Add `clear_user_stars` and `clear_user_projects_cache` calls on vote and project create/delete
- Update `make prod` to use `nproc` workers via `DEVPLACE_WEB_WORKERS` env var
- Convert `database.py` and `utils.py` to packages for modular structure
- Add `devplace apikey` and `devplace token` CLI subcommands for API key and access token management
2026-07-06 03:57:47 +00:00
retoor f1bdefd834 feat: add _sanitize_mentions helper and apply to comment/reply truncation
Introduce a new `_sanitize_mentions` method in `BotHelpersMixin` that deduplicates and removes self-mentions from text, using a compiled regex for `@handle` patterns. Apply this sanitizer before the 2000-character truncation in both `engage.py` comment posting and `social.py` reply posting to prevent duplicate or self-referential mentions from being cut off mid-handle. Additionally, fix profile URL parsing in `social.py` to strip trailing path segments, and refine `LLMClient.clean` to handle asterisks and underscores more precisely without breaking adjacent alphanumeric characters.
2026-07-04 23:08:41 +00:00
retoor 0f872336b1 feat: add online presence tracking with last_seen column and configurable timeout
Add `last_seen` column to users table with index, implement `set_last_seen` and `get_online_users` database functions, expose presence config env vars (`PRESENCE_TIMEOUT_SECONDS`, `PRESENCE_ONLINE_LIMIT`, `PRESENCE_ONLINE_MARGIN_SECONDS`), include `last_seen` in follow list responses, and update profile docs to mention online indicator.
2026-07-04 22:08:20 +00:00
retoor 7002b23eeb feat: add clickable activity cards with comment anchor links to profile tab
Add `url` field to profile activity items so post cards link to `/posts/{slug}` and comment cards link to the parent post with a `#comment-{uid}` anchor, matching notification click behavior. Include the shared `_card_link.html` overlay in the template, fix XP bar rendering for missing `xp` key, and add API + e2e tests verifying the link structure and navigation.
2026-07-04 20:24:59 +00:00
retoorandClaude Opus 4.8 c79dfd0291 refactor: split database.py into package (ref.md 3.2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:22:11 +00:00
retoor 8daee65011 refactor: split utils.py into package (ref.md 3.8) 2026-07-04 19:18:19 +00:00
retoor 7de7e29d3e refactor: split docs_api.py into package (ref.md 3.1) 2026-07-04 19:17:56 +00:00
retoor d556b02bfb refactor: split cli.py into package (ref.md 3.7) 2026-07-04 19:17:59 +00:00
retoorandClaude Opus 4.8 574b076f5d refactor: split bot.py into mixins (ref.md 3.3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:19:39 +00:00
retoor f521a1130e refactor: split devii session.py into package (ref.md 3.10) 2026-07-04 19:16:33 +00:00
retoorandClaude Opus 4.8 b57e657cf8 refactor: split news.py into package (ref.md 3.9)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:23:51 +00:00
retoor 0e8b015fe1 refactor: split schemas.py into package (ref.md 3.6) 2026-07-04 19:16:34 +00:00
retoorandClaude Opus 4.8 d45bb37ce1 refactor: split devii catalog.py into package (ref.md 3.5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:16:32 +00:00
retoor 4618089cea refactor: split game store.py into subpackage (ref.md 3.11) 2026-07-04 19:16:33 +00:00
retoor 9151876770 chore: add monster-file split plan (ref.md) 2026-07-04 19:33:00 +00:00
retoor 3fbac87723 feat: replace inline IP resolution with centralized client_ip utility across routers and audit
DevPlace CI / test (push) Failing after 36m45s
Consolidate scattered `X-Real-IP` / `request.client.host` fallback logic into the single `client_ip()` helper from `utils`, reducing duplication in the rate limiter middleware, project file zip routes, tools shared module, and audit record builder. Also refactor three admin endpoints (`/ai-usage/data`, `/analytics`, `/users/{uid}/ai-usage`) to use the existing `require_admin()` guard instead of repeating manual auth checks, and remove now-unused `get_current_user`/`is_admin` imports from those modules.
2026-07-04 18:07:33 +00:00
retoor 244a524b91 feat: add authenticated my-profile endpoint and clean breadcrumb markdown in SEO context
- Add GET /profile route returning own profile page with tab support, backed by new my_profile_page handler in profile/index.py and documented in docs_api.py
- Introduce _clean_breadcrumbs helper in seo.py that strips markdown from breadcrumb names before passing to schema generation
- Skip data-confirm modal for elements with data-auto-submit attribute in ModalManager.js
- Guard PushManager subscription against missing userUid and capture it from document body dataset
- Switch profile bio template from render_title to render_content and use div instead of span for proper block rendering
2026-07-01 13:15:33 +00:00
retoor 6f340a6818 feat: add per-user avatar seed regeneration with irreversible random avatar replacement
Implement a new `avatar_seed` column on the users table that overrides the username-based seed for Multiavatar generation. Introduce a null-safe `avatar_seed(user)` choke point in `avatar.py` that resolves `user.get("avatar_seed") or user.get("username")`, registered as a Jinja global so every render site (`_avatar_link.html`, `avatar_url(...)` calls, SEO `og_image`, issues ad-hoc dicts, devRant payload/PNG) propagates a regenerated seed. Add `POST /profile/{username}/regenerate-avatar` endpoint (owner-or-admin only) that writes a fresh `generate_uid()` to `avatar_seed`, invalidates the target's user cache, and audits `profile.avatar.regenerate`. The previous seed is overwritten and never stored, making regeneration irreversible. Document the feature in `AGENTS.md` and `README.md`, add the API endpoint to `docs_api.py`, and include the `regenerate_avatar` Devii tool in `CONFIRM_REQUIRED`.
2026-06-27 22:31:34 +00:00
retoor e773067106 feat: add test suites for backup schedules, service config, auth token, bookmarks, content permissions, devii adopt, and devrant auth
DevPlace CI / test (push) Successful in 35m0s
Add comprehensive test coverage for seven new API areas:
- Backup schedules CRUD operations in admin panel
- Service configuration saving and retrieval for news AI model
- Token authentication with email/username and JSON body support
- Bookmarking for project and news targets with invalid type validation
- Content permissions testing with member and admin session fixtures
- Devii adopt endpoint redirect behavior and session cookie handling
- Devrant authentication flow with user registration and token retrieval
2026-06-23 00:47:40 +00:00
retoor 34e33995c4 feat: add alt text extraction from URLs for images and improve CSS variable usage for progress bars
DevPlace CI / test (push) Successful in 32m28s
Add `_alt_from_url` helper in Python rendering and `altFromUrl` in JS ContentRenderer to generate descriptive alt attributes from image filenames, replacing empty alt strings. Update image embed templates and Avatar component to use derived alt text. Migrate inline `style.width` assignments to CSS custom properties (`--poll-pct`, `--hud-xp`, `--quest-pct`, `--bar-pct`, `--quota-pct`) across PollManager, GameFarm, and profile/quest CSS for better maintainability. Introduce new admin, docs, projects, services, and base CSS utility classes. Add "Code Farm" docs page entry.
2026-06-22 23:13:48 +00:00
retoor c9a4a4b280 feat: add content_preview helper and apply rendered content across templates
DevPlace CI / test (push) Failing after 31m46s
Introduce a new `content_preview()` function in `rendering.py` that strips HTML from rendered content and truncates to a given length with an ellipsis. Add a corresponding `plainText()` and `preview()` method to the `ContentRenderer` JS class. Register `content_preview` as a Jinja2 global in `templating.py`. Update multiple templates (`feed.html`, `gists.html`, `landing.html`, `messages.html`, `news.html`, `notifications.html`, `post.html`, `profile.html`) to use `render_title`, `render_content`, or `content_preview` instead of raw string slicing, ensuring consistent rendering of rich text (e.g., Markdown, HTML) in previews, descriptions, bios, and notification messages. Also fix the conversation preview in `MessagesLayout.js` to use the new JS preview method.
2026-06-22 21:17:54 +00:00
retoor e0e64c8d9e feat: add telegram notification channel with outbox service and per-user preferences
Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
2026-06-22 20:52:02 +00:00
retoor a9d0814c47 feat: restructure topnav with right-aligned icon-only tools and leaderboard
Move Leaderboard link into a right-aligned icon-only button group alongside Tools dropdown, converting both from text+caret to compact icon buttons. Adjust dropdown menu alignment from left to right and remove redundant caret styling.
2026-06-22 17:53:20 +00:00
retoor 412dbe3a3d feat: add steal mechanic to Code Farm with 60s protection window and half-coin reward 2026-06-22 17:33:03 +00:00
retoor ebf6bf7f82 feat: add Code Farm cooperative idle game with plot-based crop system and pub/sub notifications
Implement a Farmville-style cooperative idle game mounted at `/game` with member-only play and public farm viewing. The data layer is pure and timestamp-driven with no background tick: plot states are derived from `ready_at` vs current time, never stored. Key features include plantable software projects (shell script through kernel) that build over real time, harvest for coins and XP, CI tier upgrades for faster builds, plot purchasing with doubling cost, watering mechanics for friends' builds, daily quests, and prestige system. All game endpoints negotiate HTML or JSON and return full farm state for single-request client refresh. Pub/sub notifications broadcast farm updates on `public.game.farm.{username}` topics. Database schema adds `game_farms`, `game_plots`, and `game_quests` tables with appropriate indexes.
2026-06-22 16:41:53 +00:00
retoor 743efbf61f feat: add nested comment reply indicators and responsive flat-reply styling
Add `comment-reply-to` span showing parent author with ↳ indicator in comment headers, and introduce `.comment-replies-flat` dashed border style for deeply nested threads. Adjust responsive spacing: reduce comment gap and vote min-width on mobile, hide reply-to indicator on small screens, and increase reply padding for better readability.
2026-06-21 18:13:41 +00:00
retoor 19cc85f409 fix: correct audit event names, attachment deletion, redirect safety, and add account deactivation check
- Fix audit event names in CLI prune/clear commands from `cli.seo.*` to `cli.seo_meta.*`
- Refactor `_delete_attachment_file` to accept full attachment dict instead of storage_path string, using directory and stored_name fields with ATTACHMENTS_DIR
- Add `safe_next` validation for referer header in validation error redirect and media redirect
- Add `is_active` check in login router to reject deactivated accounts with "Account is deactivated" error
- Replace raw `request.headers.get("Referer")` with `redirect_back()` utility in bookmarks, polls, reactions, and votes routers
- Move `mark_conversation_read` call from `get_conversation_messages` to `messages_page` to avoid side effects during message retrieval
- Fix poll audit link to use `option.get("label")` instead of `option.get("text")`
- Add `VOTABLE` set validation in votes router to reject invalid target types with 400 response
- Strip control characters (0x00-0x20) from URLs in `_safe_url` instead of simple strip
- Add `__getattr__` fallback in services `__init__.py` for dynamic attribute access
2026-06-21 16:46:27 +00:00
retoor a3dd747d07 fix: clear rate-limit hot-settings cache and add retry loop for maintenance-mode tests
DevPlace CI / test (push) Successful in 29m11s
- Invalidate `_hot_settings_value` and `_hot_settings_at` in `_patch_limits` so stale
  cached limits from prior tests do not persist after monkey-patching `get_int_setting`.
- Add a polling retry loop (up to 5 seconds) in `test_maintenance_mode_blocks_guests`
  to handle eventual consistency of the maintenance-mode flag propagation.
- Replace brittle hardcoded `@bob_test` assertion in mention test with dynamic
  `data-username` attribute extraction from the active dropdown item.
- Replace `asyncio.run` calls in bot unit tests with shared `run_async` helper from
  `tests.conftest` to avoid event-loop conflicts in the test suite.
- Change `insert` to `upsert` with `["uid"]` conflict target in Gitea test fixture
  to prevent duplicate-key errors on repeated test runs.
- Replace `asyncio.new_event_loop().run_until_complete` in Telegram worker tests
  with the same shared `run_async` helper for consistency.
2026-06-19 23:21:53 +00:00
retoor dc8ae8a099 chore: add faker dependency to pyproject.toml for test data generation 2026-06-19 22:36:11 +00:00
retoor e393722600 feat: add author-username search to feed/gists/projects listings and partial-index migration
DevPlace CI / test (push) Failing after 2m5s
Extend `database.text_search_clause` with an `author_field` parameter that resolves username matches to user UIDs, enabling author-username search across the three public listings (`/feed`, `/gists`, `/projects`). Update the corresponding API docs and README route descriptions to reflect the new search scope. Add six partial indexes (`idx_comments_target_live`, `idx_votes_target_live`, `idx_reactions_target_live`, `idx_gists_live_created`, `idx_projects_live_created`, `idx_attachments_user_created_live`) to optimize filtered queries on non-deleted rows. Introduce a hot-settings cache (`_hot_settings`) with a 2-second TTL for `maintenance_mode`, `rate_limit_per_minute`, and `rate_limit_window_seconds`, plus a periodic rate-limit store sweep (`_sweep_rate_limit_store`) to evict stale IP entries every 60 seconds. Add `GZipMiddleware` to the FastAPI app for response compression.
2026-06-19 22:24:51 +00:00
retoor d10f1af118 feat: add seo_meta service for AI-generated SEO metadata with CLI management and database layer
DevPlace CI / test (push) Failing after 2m13s
Implement a new `SeoMetaService` subservice that generates clean SEO title/description/keywords for published content items, distinct from the existing SEO diagnostics auditor. Add `seo_metadata` polymorphic table with soft-delete support, batch query methods, and usage tracking. Extend the CLI with `seo-meta prune` and `seo-meta clear` commands for job row lifecycle management. Wire `schedule_seo_meta_for_table` into content creation and editing flows in `content.py`. Document the new service in `AGENTS.md` and `README.md`, including the `extra_head` site setting for custom `<head>` injection.
2026-06-19 20:15:22 +00:00
retoor 426d3639c6 chore: replace planning-header layout with hidden rule and restructure ticket list grid
Remove the `.planning-header` flex container and `.planning-status` spinner block, add a global `[hidden]` display rule, increase `.planning-ticket-list` max-height to 26rem, and convert ticket labels from inline flex to a CSS grid with tabular-nums on the count.
2026-06-19 15:32:47 +00:00
retoor 1350e7fc66 feat: add optional ticket selection to planning report generation
Add a `numbers` form field to the planning report endpoint, allowing
admins to generate a report for a specific subset of open tickets
instead of always planning all open tickets. The selected numbers are
parsed from a comma-separated string, deduplicated, and passed to the
background job. The job's scope is recorded in the audit log and the
Devii tool description is updated to document the new parameter.
2026-06-19 12:25:51 +00:00
retoor ff3cbbbfe2 feat: add issue_usage tracking and metrics for AI ticket enhancement and planning
Add `issue_usage` table with columns for user_uid, tokens, cost, and latency metrics, including a unique index on user_uid. Wire `accumulate_usage` into `enhance_ticket` and `generate_plan` to capture per-request AI usage, persist totals via `add_issue_usage` in both `IssueCreateService` and `PlanningReportService`, and expose aggregated usage as metric cards through `IssueTrackerService.collect_metrics`. Update planning API docs summary to reflect the new phased implementation document format.
2026-06-19 11:59:40 +00:00
retoor 7bbaf51450 feat: add file attachment support to issue tracker with Gitea mirroring
Implement full attachment CRUD for issues and comments, restricted to open issues only. Attachments are stored locally and mirrored to the Gitea tracker via new `mirror_attachment_to_gitea` and `set_gitea_asset_id` functions. Add `gitea_asset_id` column to the attachments table, extend `IssueForm` and `IssueCommentForm` with `attachment_uids` field, and expose new API endpoints for listing, adding, and deleting issue attachments. Update agent configuration to include web research tools, and document the new capability in README, AGENTS.md, and CLAUDE.md.
2026-06-19 11:22:05 +00:00
retoor a6cc83bfd2 feat: add aria.md command file for WCAG 2.2 AA+ accessibility auditing workflow
Introduce a new Claude agent command file at `.claude/commands/aria.md` that defines an accessibility specialist persona with comprehensive WCAG 2.2 AA+ auditing capabilities. The command includes detailed project rules for semantic HTML, ARIA attributes, keyboard accessibility, dynamic content handling, and landmark regions, along with a structured workflow for codebase analysis and section-by-section upgrades.
2026-06-19 09:01:10 +00:00
retoor 4ccdc5f4b8 feat: replace fixed aspect-ratio thumbnails with natural image dimensions across gallery, feed, media, and news components 2026-06-19 09:00:22 +00:00
retoor a6aa2ad357 feat: add CHANGELOG.md with release history from 2026-06-15 to 2026-06-19
Introduce a new changelog file documenting feature releases, bug fixes, and infrastructure changes across multiple dates, including user relations, news service, backup management, and gateway admin UI.
2026-06-19 08:38:56 +00:00
retoor 741d7aade6 docs: add block/mute user relations, emoji-sync CLI, and uid indexes
DevPlace CI / test (push) Failing after 2m7s
- Add `/block`, `/mute` endpoints with block/unblock and mute/unmute functionality in `routers/relations.py`, hiding blocked users' content everywhere except their own profile while muting only suppresses notifications
- Introduce `devplace emoji-sync` CLI command to regenerate `static/js/emoji-shortcodes.js` from the emoji library, documented in `CLAUDE.md` and wired in `cli.py`
- Create `get_blocked_uids()` database helper and apply it in `content.py` `load_detail()` to filter blocked users' posts from detail views
- Implement `_uid_index()` and `_drop_index()` helpers in `database.py` for unique uid indexes across tables, with `user_relations` added to `SOFT_DELETE_TABLES`
- Document new routes in `AGENTS.md` and `README.md`, including emoji shortcodes rendering behavior distinct from the emoji picker
2026-06-19 08:06:09 +00:00
retoor f3a4667fce feat: add AI markdown reformatting and usage metering to NewsService
Add AI-powered body reformatting for valid articles in the news pipeline, converting raw text walls into clean Markdown with paragraphs, headings, and lists. Introduce `news_usage` database table and `add_news_usage`/`get_news_usage` helpers to track per-cycle gateway costs (calls, tokens, latency, USD) from response headers, reported on the admin Services page. Remove unused `.sidebar-more` CSS and apply `render_title()` to poll question/label fields.
2026-06-18 23:46:53 +00:00
retoor 6ceca3d0d4 docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API
- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
2026-06-18 22:09:34 +00:00
retoor 95dca73291 feat: add DevPlace agent, auth token service, and form-data dependency modules
Add DevPlace agent configuration with dynamic OpenAPI schema fetching, implement access token issuance/resolution/revocation with configurable expiry, and create generic FastAPI dependency for JSON or form-encoded data validation against Pydantic models. Include comprehensive unit tests for form-data parsing and token lifecycle operations.
2026-06-17 17:11:47 +00:00
retoor 9598a1b867 feat: add wildcard token support for allowed extensions and access token CLI commands
Introduce WILDCARD_TOKENS set in attachments.py to treat "*", ".*", "*.*" as wildcards that fall back to ALLOWED_UPLOAD_TYPES. Add cmd_token_issue and cmd_token_list CLI commands for issuing and listing DevPlace access tokens. Register access_tokens table in SOFT_DELETE_TABLES and initialize its columns and indexes in init_db. Replace Form() with Depends(json_or_form(...)) in admin backup, container, notification, settings, and user routers to support both JSON and form data.
2026-06-17 17:10:52 +00:00
retoor 217210e02f feat: add featured/locked columns and auto-rotation logic to news pipeline
- Add `ai_grade`, `featured`, `featured_locked`, `landing_locked`, `author`, `article_published`, `image_url`, `has_unique_image` columns to news table
- Extend `news_images` schema with `alt_text`, `phash`, `width`, `height`, `is_placeholder` columns
- Create `idx_news_featured` index for efficient featured queries
- Update admin toggle endpoints to set `featured_locked`/`landing_locked` when manually toggling
- Propagate `featured` and `image_url` fields through landing page, news list, and detail page rendering
- Update `get_featured_news` to return `featured` and `image_url` fields
- Document in AGENTS.md the full zero-maintenance pipeline: image perceptual hashing, placeholder detection, AI grading on cleaned text, reliability gate, effective score computation, and post-loop landing rotation
- Update README.md service description to reflect automatic image comparison and landing rotation capabilities
- Clarify docs_api.py summaries that toggling featured/landing now locks articles from auto-rotation
2026-06-18 22:08:41 +00:00
retoor 0a554ebc32 feat: restrict backup archive download to primary admin and hide admin-hidden projects from other admins
DevPlace CI / test (push) Failing after 22m57s
- Add `get_admin_uids()` and `get_primary_admin_uid()` to database.py for resolving the earliest-created admin
- Modify `can_view_project()` in content.py so a project hidden by an admin is invisible to other admins (both web UI and REST API)
- Update `_download_url()` and `_backup_payload()` in admin/backups.py to accept a `can_download` flag, gating the download endpoint with `is_primary_admin()`
- Remove `role` from `_user_facts()` in docs_live.py to avoid leaking admin status in live docs
- Update doc summaries in docs_api.py to reflect the new admin-visibility and backup-download semantics
2026-06-17 14:08:28 +00:00
retoor 6b5347103b fix: replace flex shorthand with single value and refactor mobile keyboard inset logic in messages layout 2026-06-16 15:22:41 +00:00
retoor dcb3f08ac1 feat: add name-based lookup fallback and terminal session management for containers
Add name field as third fallback in get_instance lookup chain to support
container retrieval by display name. Introduce new terminal.py module
with async PTY session handling, pubsub-based I/O streaming, resize
control, and audit logging for container exec sessions.
2026-06-16 14:54:22 +00:00
retoor 35040204c9 fix: comment out optimistic message append to prevent duplicate bubbles in MessagesLayout 2026-06-16 14:18:19 +00:00
retoor 7bc67662fa feat: add provider and model routing tables, admin UI, and audit category for gateway
Implement the multi-provider routing system for the OpenAI gateway, including two new database tables (`gateway_providers`, `gateway_models`) ensured at init, a new admin page at `/admin/gateway` with full CRUD for providers and model routes, and a `"gateway"` audit category mapped to `"ai"`. The routing layer sits transparently on top of the existing single-provider default path: unmatched model names fall through unchanged, while matched routes forward to the configured provider with their own pricing economy, vision model, and context window. Cross-worker cache invalidation uses a shared `_ROUTING_CACHE` bumped via `"gateway_routing"` cache version.
2026-06-16 22:11:14 +00:00
retoor c100b4b692 feat: add DevPlaceCode binary and integrate into Docker image
DevPlace CI / test (push) Successful in 25m6s
Copy the `dpc` binary into the container at `/usr/bin/dpc` and set its permissions to 0755 alongside other executables, enabling the DevPlaceCode service within the containerized environment.
2026-06-16 18:38:33 +00:00
retoor 82f961c660 feat: add router table entries for uploads, media, zips, forks, tools, proxy, push, docs, openai, devii, xmlrpc, devrant, dbapi, pubsub and add type annotations to cache, config, responses, schemas, seo, and stealth modules 2026-06-16 12:05:44 +00:00
retoor a618a95671 feat: remove .html and .svg from allowed upload types and MIME mappings
Remove HTML and SVG file extensions from the ALLOWED_UPLOAD_TYPES dictionary and their corresponding MIME type entries from MIME_TO_EXT in attachments.py, preventing users from uploading these potentially unsafe file formats through the API.
2026-06-16 06:50:16 +00:00
retoor 99ed5c4f15 feat: add audit logging for admin trash restore/purge and notification clear actions
- Record audit events in `admin_trash_restore` and `admin_trash_purge` endpoints with target metadata
- Log `notification.read.all` event when user clears devRant notification feed
- Include `devrant` as a valid origin in audit categories
- Add `admin_section` and `pagination_query` fields to audit and backup schemas for UI consistency
2026-06-16 05:08:58 +00:00
retoor 1934dd5727 feat: add seed helpers for gist, news, and project comment hierarchies in e2e tests
DevPlace CI / test (push) Failing after 23m2s
Introduce `_seed_gist_with_comments`, `_seed_news_with_comments`, and `_seed_project_with_comments` helper functions that create a user, a parent entity, and four comments (excluded, flat, parent, reply) with a shared marker prefix and timestamps, enabling consistent comment tree seeding across test modules.
2026-06-16 04:06:16 +00:00
retoor 15bd4ad87c feat: add backup CLI commands, AI correction/modifier services, and timezone-aware date display
DevPlace CI / test (push) Failing after 25m18s
- Add `devplace backups` CLI subcommands (list, run, prune, clear) with job enqueueing and orphan cleanup
- Introduce `BACKUPS_DIR` and `BACKUP_STAGING_DIR` config paths for backup storage
- Implement `schedule_correction` and `schedule_modification` calls in content creation, comment creation, and comment editing flows
- Add `DEFAULT_CORRECTION_PROMPT` and `DEFAULT_MODIFIER_PROMPT` config constants for AI content processing
- Document timezone-aware date display using `local_dt`/`dt_ago` Jinja globals with client-side `Intl` localization
- Update README with AI content correction/modifier support in direct messages via `@ai` inline instructions
- Add `track_action(user["uid"], "vote")` call on upvote in `apply_vote`
2026-06-16 03:32:19 +00:00
retoor e59bc2d34e feat: adopt per-bot account api key for gateway attribution and improve post distinctness
- Add `account_api_key` field to `BotState` and `_adopt_account_api_key()` method that fetches the bot's own `users.api_key` from its profile JSON after auth, switching `LLMClient` to use it instead of the shared bootstrap key, routing gateway spend through the bot's user identity
- Replace `interleave_by_author` with a simpler greedy algorithm that picks the first row whose owner differs from the last picked, removing the multi-queue priority logic
- Extend `_ai_quota` response with `unlimited`, `requests`, and `last_used` fields sourced from new `user_spend_24h()` analytics helper
- Pass `recent_post_titles` from `BotState` into `LLMClient.generate_post()` and add a `distinct_rule` prompt instructing the model to avoid repeating framing, examples, or opinions from the bot's last six posts
- Remove early-return dedup check in `ArticleRegistry.admit` that skipped articles already held by the same bot owner, allowing re-admission under a different category
- Render bot username as a clickable link with avatar in the admin bots table
2026-06-15 22:44:12 +00:00
retoor 5dd2126511 feat: add gist comment form and extend comment container selector to include gist-card
Add the comment form inclusion to the gist card template by setting the target uid and type variables before rendering the shared `_comment_form.html` partial. Also update the `CommentManager.js` closest selector to include `.gist-card` alongside `.post-card` and `.comments-section`, ensuring the comment source lookup works correctly for gist comment forms.
2026-06-15 16:38:32 +00:00
retoor 6b08761af5 feat: add audio file support and expand allowed upload types with new MIME mappings
Extend ALLOWED_UPLOAD_TYPES dict with audio formats (wav, flac, ogg, aac, wma, m4a), video formats (avi, mkv, flv, wmv, 3gp), document formats (doc, docx, xls, xlsx, ppt, pptx, odt, rtf), code formats (ts, java, cpp, c, h, rb, go, rs, sql, php, swift, kt), config formats (cfg, ini, log), archive formats (tar, gz, rar, 7z), and additional web formats (html, json, xml, yaml, yml, toml, sh, bat, svg). Add corresponding MIME_TO_EXT reverse mappings. Implement audio element rendering in ContentRenderer.js with audioExtRe regex and createAudioElement method. Add gallery-audio CSS class for audio player styling in attachments.css and media.css. Update _attachment_display.html and _media_gallery.html templates to render audio controls for is_audio attachments.
2026-06-15 15:08:10 +00:00
retoor e1874f9b6a feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
DevPlace CI / test (push) Failing after 21m53s
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 12:10:14 +00:00
retoor 3c7527988e feat: add multi-part message splitting with sentence-aware chunking for bot responses
Implement `_split_sentences`, `_hard_chunks`, and `_pack` helpers to break long bot messages into parts respecting sentence boundaries and character limits. Introduce `COMMENT_CHAR_LIMIT`, `MESSAGE_CHAR_LIMIT`, `PART_SUFFIX_RESERVE`, and `PART_DELIVERY_DELAY` constants to control chunk size, suffix overhead, and inter-part delay. This enables the bot to deliver responses exceeding single-message limits across multiple sequential messages.
2026-06-15 11:09:22 +00:00
retoor 9f51563db4 feat: replace startup event with lifespan context manager and add pyproject.toml filterwarnings
The lifespan context manager replaces the deprecated `@app.on_event("startup")` pattern, centralizing initialization logic for data directories, database, certificates, and all service registrations. It also conditionally starts background services based on the `DEVPLACE_DISABLE_SERVICES` environment variable and acquires a service lock for worker coordination. Additionally, the `pyproject.toml` now suppresses three specific deprecation and syntax warnings during test runs.
2026-06-15 10:57:09 +00:00
retoor d31ccba6c1 feat: add admin/internal database API with CRUD, read-only query, and natural-language SQL endpoints
Add a new `/dbapi` router package providing a generic database API over `dataset`, restricted to admin sessions, admin API keys, and the internal gateway key. Includes:

- `tables.py`: list all tables and inspect table schemas
- `crud.py`: full CRUD operations (GET, POST, PATCH, DELETE) with soft-delete awareness, born-live inserts, `?include_deleted`, `.../restore`, and `?hard=true` purge
- `query.py`: validated read-only SELECT execution via sqlglot parsing, classification, and EXPLAIN dry-run; async query jobs with WebSocket streaming via `DbApiJobService`
- `nl.py`: natural-language-to-SQL conversion using the platform AI gateway with re-prompting until validation passes

Also register `DbApiJobService` and `PubSubService` in the service manager, add `DBAPI_DIR` to config data paths, and force cleartext `http://` connections to HTTP/1.1 in `curl_transport` to fix large request failures against uvicorn's HTTP/1.1-only internal gateway.
2026-06-14 23:00:30 +00:00
1414 changed files with 143175 additions and 21521 deletions
+53
View File
@@ -0,0 +1,53 @@
---
name: DevPlace
description: Dynamic API operator for the DevPlace instance at pravda.education. Fetches https://pravda.education/openapi.json at the start of every run, reads the live schema to discover the exact endpoints/parameters/payloads available, and carries out whatever task it is given by calling that API. Use when a task should be accomplished against the pravda.education DevPlace API (posting, reading feeds/projects/profiles, file operations, container operations, search, or any other documented endpoint). Cleans up every temporary file it creates before finishing.
tools: Read, Write, Bash
model: inherit
color: green
---
You are the **DevPlace** agent. You operate the live DevPlace instance hosted at `https://pravda.education` exclusively through its HTTP API, which you discover dynamically from its OpenAPI document on every run. You never assume the API shape from memory; the fetched schema is the single source of truth for what exists and how to call it.
## Base
- Base URL: `https://pravda.education`
- OpenAPI document: `https://pravda.education/openapi.json`
- The document's `info.title` is "DevPlace". It exposes a server-rendered social network for developers (posts, comments, projects with a virtual filesystem, profiles, gists, news, containers, search, and more).
## Operating protocol (follow in order, every run)
1. **Fetch the schema first, always.** Before doing anything else, download the OpenAPI document to a uniquely named temp file under `/tmp` (for example `/tmp/devplace_openapi_$$.json`):
```bash
curl -fsS https://pravda.education/openapi.json -o /tmp/devplace_openapi_$$.json
```
If the fetch fails (non-zero exit, empty body, or non-JSON), stop and report the failure with the exit code and any response body. Never fall back to a hardcoded or remembered API shape.
2. **Parse and understand.** Use Python (`python3 -c ...` or a temp script) to load the JSON and locate the endpoints relevant to the task: match the task intent against `paths`, inspect each candidate operation's `parameters`, `requestBody` schema (resolve `$ref` into `components.schemas`), and `responses`. Confirm the exact path, method, required parameters, and request content type (`application/x-www-form-urlencoded`, `application/json`, or `multipart/form-data`) before issuing any call. Prefer reading the schema over guessing.
3. **Resolve authentication.** Authenticated endpoints accept a DevPlace `api_key` via the `Authorization: Bearer <key>` header or the `X-API-KEY: <key>` header. Resolve the key from the environment in this order and use the first that is set: `$DEVPLACE_API_KEY`, `$PRAVDA_API_KEY`, `$API_KEY`. If no api_key is available, fall back to the default account credentials below by logging in (`POST /auth/login` with `email`/`password`) to obtain a `session` cookie, and use that cookie for subsequent authenticated calls. Never print a resolved key or password value in your output.
**Default credentials (used only when the task itself supplies no account/credentials):**
- email: `claudetest@molodetz.nl`
- username: `claudetest`
- password: `claudetest`
Use these whenever an action needs an authenticated DevPlace user and the task did not name one. If the task explicitly provides its own credentials, those always take precedence over this default. If even these fail, attempt the public/unauthenticated path if one exists; otherwise stop and report the failure. Never invent or guess a different key, and never print a resolved key value in your output.
4. **Execute the task.** Carry out the requested work by calling the discovered endpoints, in any combination required (read endpoints to gather context, then write endpoints to act). Chain calls when a task needs several steps (for example: search for a resource, then operate on the returned identifier). Send form bodies as `--data-urlencode` for `application/x-www-form-urlencoded` operations and `-H 'Content-Type: application/json' --data @file` for JSON operations, matching what the schema declares for that operation. Always send `-fsS` (or check the HTTP status explicitly) so a server error is never silently ignored.
5. **Verify.** After a state-changing call, confirm the result from the response body or with a follow-up read call when one is available. Report the concrete outcome (created identifier, slug, URL, affected count), not a vague "done".
## Temporary files (mandatory cleanup)
- Create every temporary file under `/tmp` with a run-unique name (use `$$` or `mktemp`). Track every path you create.
- **Before you finish - on success, on failure, and on early exit - delete every temporary file and directory you created** (the OpenAPI dump, any request-body files, any downloaded artifacts, any temp scripts). A `trap 'rm -f "$tmpfile" ...' EXIT` in a single Bash invocation, or an explicit `rm` step, is acceptable; either way leave `/tmp` exactly as you found it.
- Do not write temporary files anywhere outside `/tmp`, and never inside the repository working tree.
## Safety and scope
- Operate ONLY against `https://pravda.education`. Do not call any other host.
- State-changing operations (create/edit/delete, file mutations, container lifecycle, anything POST/PUT/PATCH/DELETE) act on a live system. Perform exactly the mutation the task asks for - never broaden scope, never delete or overwrite anything the task did not name. If a destructive action is ambiguous, stop and ask rather than guess.
- Treat the fetched schema as authoritative for the current run only; re-fetch on every invocation so you always reflect the deployed API.
- Be concise and factual in your final report: state which endpoints you called (method + path), the inputs you sent (excluding secrets), and the result returned.
## Output
Return a short, business-like summary: the task as you understood it, the sequence of API calls made (method and path), the outcome with concrete identifiers/URLs, and explicit confirmation that all temporary files were removed. No emoticons, no filler.
+1 -1
View File
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
+3 -3
View File
@@ -40,10 +40,10 @@ The already-established **choke points** (the public function is a thin wrapper
- **C. Cross-reference before every change.** A funnel is called from many sites; deferring inside it changes ALL of them. Find every caller and confirm none depends on the side-effect's result synchronously. If even one does, do not defer the funnel.
- **D. Zero degradation.** A fix must never reduce functionality, weaken a check, or change observable behavior beyond moving WHEN a side-effect runs. Deferral is best-effort and must NEVER raise into the caller. If the only fix would degrade or risk stale reads, record it unfixed with the safe path forward.
- **E. Dig deep.** Pursue the root cause; gather more evidence rather than guessing or bailing.
- **F. Verify your own work.** After each edit, re-read the changed region, re-check the callers, confirm `python -c "from devplacepy.main import app"` still imports clean, and run `hawk .`.
- **F. Verify your own work.** After each edit, re-read the changed region, re-check the callers, confirm `python -c "from devplacepy.main import app"` still imports clean, and re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates).
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, confirm the app imports clean, then run `hawk .` and confirm it passes. **HARD GUARDRAIL: never run the test suite (no `make test`, no `pytest`); never perform any git write operation.** Validate by clean import + hawk + an em-dash scan only.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, confirm the app imports clean, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm they pass. **HARD GUARDRAIL: never run the test suite (no `make test`, no `pytest`); never perform any git write operation.** Validate by clean import + the per-language checks + an em-dash scan only.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); full typing on functions you add; keep `retoor <retoor@molodetz.nl>` as the first line of any source file you create.
@@ -78,4 +78,4 @@ FIX: move the side-effect into a thin public wrapper that `background.submit(_wo
- **wrong-tool**: grep `background.submit(` for any argument that is an `async def`/coroutine function, and any external-client call (gitea/push/AI) deferred via the sync queue.
## Output
Return a markdown report: a one-line summary, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name (e.g. `missing-deferral`, `double-wrap`, `unsafe-deferral`, `bypass-funnel`, `wrong-tool`, `captured-request`, `broken-wiring`), the message, and (in fix mode) whether it was fixed. End with the verification you ran (clean import, `hawk .`, em-dash scan) and its result. Never claim the test suite was run.
Return a markdown report: a one-line summary, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name (e.g. `missing-deferral`, `double-wrap`, `unsafe-deferral`, `bypass-funnel`, `wrong-tool`, `captured-request`, `broken-wiring`), the message, and (in fix mode) whether it was fixed. End with the verification you ran (clean import, the per-language checks, em-dash scan) and its result. Never claim the test suite was run.
+1 -1
View File
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
+9 -6
View File
@@ -1,6 +1,6 @@
---
name: docs-maintainer
description: Documentation coverage and role-aware show/hide maintainer. Keeps CLAUDE.md, AGENTS.md, README.md, docs_api.py, and the /docs prose pages in exact agreement with the source, and keeps admin material gated at both page and section level. Use when reviewing API docs coverage, prose accuracy, or docs role gating.
description: Documentation coverage and role-aware show/hide maintainer. Keeps every CLAUDE.md (root and nested per-subsystem), README.md, docs_api.py, and the /docs prose pages in exact agreement with the source, and keeps admin material gated at both page and section level. Use when reviewing API docs coverage, prose accuracy, or docs role gating.
tools: Read, Grep, Glob, Edit, Write, Bash
model: inherit
color: blue
@@ -30,29 +30,32 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
## Your dimension
Keep `CLAUDE.md`, `AGENTS.md`, `README.md`, and the `/docs` pages in exact agreement with the source, and keep role-based visibility consistent so admin material is shown to admins and hidden from members and guests at both the page and the section level.
Keep every `CLAUDE.md`, `README.md`, and the `/docs` pages in exact agreement with the source, and keep role-based visibility consistent so admin material is shown to admins and hidden from members and guests at both the page and the section level.
**`CLAUDE.md` is split, not monolithic.** The root `/CLAUDE.md` holds only cross-cutting rules (Claude Code loads it eagerly, every session). Each subsystem directory (e.g. `devplacepy/services/devii/`, `devplacepy/routers/projects/`, `devplacepy/database/`, `tests/`) has its own nested `CLAUDE.md` with that subsystem's full mechanic/pitfall/gotcha coverage, loaded automatically by Claude Code only when a file in that directory is read or edited. There is no `AGENTS.md` - it was removed and its content redistributed into the root file plus the nested files. **Treat the reappearance of a top-level `AGENTS.md`, or any doc/prose page referencing one, as an error to fix (delete the file / repoint the reference at the correct root-or-nested `CLAUDE.md`).**
DETECT:
- Every public or authenticated REST route has a `docs_api.endpoint()` entry in the correct group, with params and a `sample_response`. A documented route whose params drifted from the actual Form model is an error.
- Every prose page's factual claims match the code (routes, env vars, defaults, behavior). A stale claim is an error.
- `README.md` reflects current routes, env vars, dependencies, and user-visible features. `AGENTS.md` has a domain section for every mechanic. `CLAUDE.md` changes only for a new architectural rule.
- `README.md` reflects current routes, env vars, dependencies, and user-visible features. Every nested `CLAUDE.md` has full coverage of its subsystem's mechanics/pitfalls, and the root `CLAUDE.md`'s "Subsystem map" table lists every nested `CLAUDE.md` that actually exists (no stale entry for one that was deleted, no missing entry for one that was added). Root `CLAUDE.md` changes only for a new cross-cutting architectural rule.
- No file references a top-level `AGENTS.md` (grep the repo, excluding `.venv/`, `*.bak`, `.git/`, and the `agents/` exclusion above). A hit is an error - repoint it at the root or the correct nested `CLAUDE.md`.
- Page-level role gating: admin-only pages carry `"admin": True` in their `DOCS_PAGES` entry; the router filters the sidebar to `visible_pages` and 404s a non-admin requesting an admin page, while `docs_search` still indexes admin pages for admins. An admin page missing the flag, or a member page wrongly flagged admin, is an error.
- Section-level role gating: prose templates receive the user context via `docs_prose.render_prose` and gate admin sections with Jinja `{% if user %}` / `{% if user.role == 'admin' %}`. Unguarded admin material on a public page is an error.
FIX: add or repair the `endpoint()` entry, rewrite the stale prose, add the missing `README.md` / `AGENTS.md` section, add the `"admin": True` flag, or wrap the leaking section in the correct Jinja guard. The source is authoritative; correct the docs to match the code, never the reverse.
FIX: add or repair the `endpoint()` entry, rewrite the stale prose, add the missing `README.md` section or nested `CLAUDE.md` section, repoint or delete a stray `AGENTS.md` reference, add the `"admin": True` flag, or wrap the leaking section in the correct Jinja guard. The source is authoritative; correct the docs to match the code, never the reverse.
## Scope units
- **api-docs**: `devplacepy/docs_api.py` `endpoint()` coverage vs `routers/*.py` routes.
- **page-gating**: `devplacepy/routers/docs/pages.py` `DOCS_PAGES` admin flag; `visible_pages` filter; `docs_search` indexing.
- **section-gating**: `templates/docs/*.html` Jinja `{% if user.role == 'admin' %}` on admin sections.
- **readme**: `README.md` reflects current routes, env vars, dependencies, features.
- **agents-md**: `AGENTS.md` has a domain section for every mechanic; `CLAUDE.md` only for new rules.
- **claude-md-nested**: every nested `CLAUDE.md` has a domain section for every mechanic in its subsystem; root `CLAUDE.md` only for new cross-cutting rules; no stray `AGENTS.md` file or reference anywhere in the repo.
## Output
Return a markdown report: a one-line summary line, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name, the message, and (in fix mode) whether it was fixed.
+1 -1
View File
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
+2 -2
View File
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
@@ -45,7 +45,7 @@ DETECT, for each route:
- A `services/devii/actions/catalog.py` Action exists if the route is something a user could ask Devii to do.
- A `docs_api.py` entry exists for every public or authenticated endpoint.
- Public pages build `base_seo_context`.
- `README.md` and `AGENTS.md` mention the feature.
- `README.md` and the relevant nested `CLAUDE.md` mention the feature.
FIX: add the missing Form, add the missing key to the `*Out` schema, switch the handler to `respond`, or flag the responsible specialist's layer. When a layer is intentionally absent (an internal route with no public docs, a route Devii should never call), record an info finding with the rationale rather than fabricating the layer.
+21 -7
View File
@@ -1,7 +1,7 @@
---
name: feature-builder
description: Feature author and updater. Creates a new DevPlace feature or extends an existing one coherently across the full fan-out (data layer, server, view, agent, docs, SEO, tests) so no connected layer is forgotten. The constructive counterpart to the maintainer fleet - it writes the feature, the maintainers verify it. Use when adding a new route/capability or growing an existing one.
tools: Read, Grep, Glob, Edit, Write, Bash
description: Feature author and updater. Researches the task first (codebase, and the web for any external API, protocol, library, or spec), then creates a new DevPlace feature or extends an existing one coherently across the full fan-out (data layer, server, view, agent, docs, SEO, tests) so no connected layer is forgotten, and reports what must be restarted to go live. The constructive counterpart to the maintainer fleet - it writes the feature, the maintainers verify it. Use when adding a new route/capability or growing an existing one.
tools: Read, Grep, Glob, Edit, Write, Bash, WebSearch, WebFetch
model: inherit
color: blue
---
@@ -18,12 +18,19 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
Default to **PLAN** mode. Investigate the area, then return a layer-by-layer implementation plan and STOP - do not write code until the invocation approves the plan or explicitly asks you to implement directly ("implement", "just do it", "no plan needed"). Once approved (or when invoked in implement mode), build the whole feature, then validate. Never run the test suite; never perform any git write operation.
## Operating protocol
1. **Understand before writing.** Read the router, template, matching tests, the relevant `CLAUDE.md`/`AGENTS.md` domain section, and trace the existing data flow (input model -> router -> data helper -> HTML and JSON response) before proposing anything. Reuse beats re-implementation: find the canonical helper/partial/component and use it.
1. **Understand before writing.** Read the router, template, matching tests, the relevant nested `CLAUDE.md` (each subsystem directory has its own, e.g. `devplacepy/services/devii/CLAUDE.md`) and the root `CLAUDE.md` for any cross-cutting rule, and trace the existing data flow (input model -> router -> data helper -> HTML and JSON response) before proposing anything. Reuse beats re-implementation: find the canonical helper/partial/component and use it.
2. Use Grep/Glob for discovery; read the relevant range, not whole large files. Never repeat a grep or re-read a file you already read.
3. Match the surrounding code: its naming, structure, comment density (none), and idioms. A new feature must be indistinguishable in style from the area it lives in.
4. Build the fan-out coherently in one pass - changing one layer and forgetting a connected one is the cardinal failure here.
5. Stay constructive and minimal. Touch only what the feature needs; do not refactor unrelated code (note an unrelated problem at most once and leave it). Respect "refactor only what you touch."
## Research the task before designing (codebase first, web when external)
Investigation is two passes, in order:
1. **Codebase pass (always).** Read the router, template, matching tests, and the relevant nested `CLAUDE.md` (plus the root `CLAUDE.md` for cross-cutting rules); trace the existing data flow (input model -> router -> data helper -> HTML and JSON response); find the canonical helper, partial, or component to reuse. Never design from assumption when the answer is in the repo.
2. **Web pass (whenever the feature touches anything outside this repo).** If the work integrates a third-party API or protocol, a library's correct usage, a new dependency, a file format, standard, or spec, external provider or model behavior, or a security consideration, run a focused WebSearch/WebFetch pass BEFORE designing. Pull the authoritative, current contract - exact endpoints, parameters, request and response shapes, auth, limits, version differences, and known bugs or quirks - and cite the sources in your plan. Prefer official docs and corroborate version-specific details. Do not design an external integration from memory: one wrong assumption about the external contract (a field name, an auth header, a documented bug such as a query-param that must be avoided) silently breaks the feature. Skip this pass only for purely internal features with no external surface.
When the external contract and the internal system must meet (for example an external API mirrored onto an internal store), resolve every mismatch in the plan - identity and ownership mapping, allowed-value or type differences, failure and partial-failure handling - before writing code.
## The fan-out (build every applicable layer; this is your core checklist)
A DevPlace feature is one data source fanning out into several consumers, all from the same handler. Ordered by data flow:
@@ -34,8 +41,8 @@ A DevPlace feature is one data source fanning out into several consumers, all fr
5. **View** - templates extend `base.html` (page CSS in `extra_head`, page JS in `extra_js`); import the shared `templates` from `devplacepy.templating`, never instantiate `Jinja2Templates`. Wrap every static asset URL in `static_url(...)`/`assetUrl(...)`. Reuse partials (`_avatar_link.html`, `_user_link.html`, `_sidebar_search.html`) and the shared frontend utilities (`Http`, `Poller`, `JobPoller`, `OptimisticAction`, `FloatingWindow`, the `dp-*` components) - never hand-roll fetch/polling. JS is ES6 modules, one class per file, on `app`. Dates are DD/MM/YYYY via `format_date`.
6. **Agent + docs (the most-forgotten layers)** - if a user could ask Devii to do it, add an `Action` in `services/devii/actions/catalog.py` with auth flags matched to the route guard (and a declared `confirm` boolean for any irreversible action added to `CONFIRM_REQUIRED`). Add a `docs_api.py` `endpoint()` entry (params + `sample_response`) for every public/auth endpoint; add a prose page to `routers/docs/pages.py` `DOCS_PAGES` when warranted. State-changing actions need an audit event (`events.md` key, `category_for`, recorder call at the mutation point).
7. **SEO** - public pages build `base_seo_context` and the right JSON-LD; add to `routers/seo.py` sitemap when indexable.
8. **Docs of record** - update `README.md` (product-facing) and `AGENTS.md` (deep companion) for any new route/config/dependency/mechanic; update `CLAUDE.md` only when a NEW architectural rule or convention is introduced.
9. **Tests** - add tests in the correct tier and path (`tests/{unit,api,e2e}/<endpoint>.py`, directory tree mirrors the URL/source path) following the required Playwright patterns. WRITE them; NEVER run them.
8. **Docs of record** - update `README.md` (product-facing) and the relevant nested `CLAUDE.md` (deep companion for the subsystem you touched - create one if the directory doesn't have one yet) for any new route/config/dependency/mechanic; update the root `CLAUDE.md` only when a NEW cross-cutting architectural rule or convention is introduced, and add a row to its "Subsystem map" table if you created a new nested `CLAUDE.md`.
9. **Tests (a hard project requirement, never optional)** - the DevPlace suite is one test file per endpoint, ~932 tests, split into three tiers with the directory tree mirroring the URL/source path. Every feature gets a test in EVERY tier it exercises: `tests/unit/` for a new data/query helper (pure in-process, `local_db` or no fixture, path mirrors the SOURCE module - `devplacepy/utils.py` -> `tests/unit/utils.py`); `tests/api/` for a new JSON or HTML route (HTTP integration against the live uvicorn subprocess via `app_server`/`seeded_db`, path mirrors the endpoint - `POST /auth/login` -> `tests/api/auth/login.py`) - but when a route depends on an in-process injected fake or a module-level singleton the separate uvicorn subprocess cannot see (the Gitea client via `runtime.set_client(fake)`, or any other `set_client`/monkeypatched backend), test it IN-PROCESS instead with `from starlette.testclient import TestClient; TestClient(m.app)`, the fake set in the test process, and auth via a `create_session(uid)` `session` cookie, asserting JSON with `Accept: application/json` (the `tests/api/issues/` files are the canonical example); `tests/e2e/` for a new interactive UI flow (Playwright `page`/`alice`/`bob`, path mirrors the endpoint - `GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`). A route or feature with no test in any tier is incomplete. Follow the required patterns (`wait_until="domcontentloaded"` on every `goto`/`wait_for_url`, scoped selectors, `try/finally` restore of any flipped global setting, the shared fixtures, `test_`-prefixed functions in non-prefixed files, born-live `deleted_at`/`deleted_by` on raw soft-delete inserts) and create any missing package directories (`__init__.py`). WRITE them; validate each by a clean import only; NEVER run them.
When a layer is intentionally absent (an internal route with no public docs, a route Devii must never call), say so explicitly in the plan with the rationale rather than fabricating the layer.
@@ -49,8 +56,15 @@ When a layer is intentionally absent (an internal route with no public docs, a r
No comments or docstrings in source you author; full typing on every signature and variable; `pathlib` over `os`; dataclasses over fixed-key dicts; no magic numbers; no version pinning; no em-dashes anywhere (use a hyphen) in any file you touch. Keep `retoor <retoor@molodetz.nl>` as the first line (correct comment style for the language) of any NEW source file you create - never of the existing files you edit, and never inside a `.md` with YAML frontmatter.
## Validation (after implementing; never skip)
Run `hawk .` and confirm zero errors. Confirm `python -c "from devplacepy.main import app"` imports clean. Grep your touched files for em-dashes and confirm none. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
There is NO validator binary in this environment - validate each touched file directly, using the Python interpreter where `import devplacepy` resolves its dependencies (verify that first; the repo `.venv` may be incomplete). Then: confirm `python -c "from devplacepy.main import app"` imports clean; compile or parse every touched language (`python -m py_compile <files>` for Python, `node --check <file>` for JS, brace balance for CSS, tag and `{% %}`/`{{ }}` balance for templates); and grep every touched file for em-dashes - the character AND the entity forms `&mdash;`/`&#8212;`/`&#x2014;` - confirming none. For any new `*Out` schema, `model_validate` it against a representative context dict so a key mismatch surfaces now, not at request time. Do NOT run the test suite. Then hand off: name which maintainer dimensions are most relevant to the change (e.g. fanout, security, dry, docs, seo, audit, frontend, style, test) so the fleet can verify it.
## Live verification of UI/API changes (mandatory for visual work)
A structurally valid template can still render broken - the static checks and the import check never open a browser. Per CLAUDE.md this project treats live verification as non-negotiable for any layout, styling, component, responsive, or backend change:
- Do not assume any verification CLI is installed (`mole`, `falcon`, `hound` are NOT present here); check with `command -v` first and fall back to the steps below or the project's `screenshot`/`serve`/`validate` skills when they exist.
- When your change touches `templates/` or `static/`, the rendered result MUST be visually verified: start the dev server (`make dev` in the background; confirm it is healthy on `http://localhost:10500`), capture each new/changed route with headless Playwright (`wait_until="domcontentloaded"`), and inspect the screenshot against the intended UI and the surrounding design system (tokens, spacing, responsiveness). Tear down any server you started.
- When your change touches `routers/`, verify the endpoints over HTTP against the live server (an api-spec runner if available, otherwise `curl`/`httpx` asserting the status and a body fragment).
- The `/feature` workflow performs this live `Verify` phase for you automatically; when you are invoked standalone for UI/API work, perform it yourself before declaring the work complete, or explicitly state it is the caller's responsibility and name the routes to check.
## Output
- In PLAN mode: a short situation summary of the area, then the ordered layer-by-layer plan (each layer: what file, what change, or "n/a - rationale"), then the list of maintainer dimensions that will need to verify it. End by asking for approval to implement.
- In IMPLEMENT mode: a concise summary of what was built per layer (`file:line` references), the validation results (`hawk`, import, em-dash scan), and the recommended maintainer hand-off.
- In IMPLEMENT mode: a concise summary of what was built per layer (`file:line` references), the validation results (import, per-language compile/parse, em-dash scan, schema model-validate), the recommended maintainer hand-off, and a DEPLOYMENT NOTE whenever you added or changed a DB column or any Python module - production runs a long-lived uvicorn with no `--reload`, so the change is NOT live until the server is restarted/rebuilt (`make docker-bup`), and a new queried column needs that restart for `init_db()` to create it (templates and CSS auto-reload, but boot-versioned static assets need the restart to bust cache). State this so the caller restarts rather than assuming the edit is live.
+1 -1
View File
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Visual judgement is out of scope for auto-fix and is recorded as a finding. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Visual judgement is out of scope for auto-fix and is recorded as a finding. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
+1 -1
View File
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
+1 -1
View File
@@ -30,7 +30,7 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: style-maintainer
description: Coding-rule compliance. Enforces the explicit CLAUDE.md and AGENTS.md coding rules across all source - forbidden naming (context-aware), no comments/docstrings, em-dash (context-aware), full typing, pathlib over os, dataclasses over fixed-key dicts, no version pinning, file headers, no magic numbers. Use for style/convention review. Most surface name/em-dash hits are false positives - run the decision algorithm.
description: Coding-rule compliance. Enforces the explicit CLAUDE.md (root and nested per-subsystem) coding rules across all source - forbidden naming (context-aware), no comments/docstrings, em-dash (context-aware), full typing, pathlib over os, dataclasses over fixed-key dicts, no version pinning, file headers, no magic numbers. Use for style/convention review. Most surface name/em-dash hits are false positives - run the decision algorithm.
tools: Read, Grep, Glob, Edit, Write, Bash
model: inherit
color: orange
@@ -30,13 +30,13 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After each edit, re-read the changed region and re-check the consumers.
## Mode
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then run `hawk .` and confirm it passes. A rename is auto-applied ONLY for a confirmed local/private name that passed the decision algorithm AND only after you grep and update every reference in the same run. Never run the test suite; never perform any git write operation.
Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX** mode only when the invocation explicitly asks you to fix. In FIX mode: reconfirm each finding survives refutation, run the cross-reference impact check, apply a minimal idiomatic root-cause fix, re-read the change, then re-validate every file you touched with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and confirm `python -c "from devplacepy.main import app"` still imports clean. A rename is auto-applied ONLY for a confirmed local/private name that passed the decision algorithm AND only after you grep and update every reference in the same run. Never run the test suite; never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
## Your dimension
Enforce the explicit CLAUDE.md and AGENTS.md coding rules across all source.
Enforce the explicit CLAUDE.md (root plus every nested per-subsystem `CLAUDE.md`) coding rules across all source.
### Forbidden naming prefixes and suffixes (CONTEXT-AWARE)
The banned tokens are `_new`, `_old`, `_current`, `_prev`, `_next` (outside iteration), `_temp`, `_tmp`, `_v1`/`_v2`/`_v3`, `better_`, `best_`, `simple_`, `my_`, `the_`, `_data`, `_info`, and the rest of the forbidden list. This rule targets LAZY, RENAMEABLE VARIABLE AND HELPER names you own. It is NOT a blind substring sweep, and most surface hits on `_data`/`_info`/`_item`/`_val` are FALSE POSITIVES. Run this decision algorithm for EVERY candidate before recording it, and skip it the moment any test fails:
+14 -6
View File
@@ -30,21 +30,29 @@ All application code is under `devplacepy/`: `devplacepy/routers/`, `devplacepy/
- **F. Verify your own work.** After writing a test module, validate it ONLY by a clean import (`python -c "import tests..."` or `python -m py_compile`).
## Mode
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER run the suite, not the full suite and not a single file.** Validate only by a clean import of the new test module. Never perform any git write operation.
Default to **REPORT** mode: record coverage gaps and pattern violations, do NOT write files. Apply **FIX** mode only when the invocation explicitly asks you to fix; then write the missing integration test following the required patterns. **HARD GUARDRAIL: write tests but NEVER launch the suite yourself, not the full suite and not a single file - the serial single-process suite cannot run concurrently with other agents, so the orchestrating session runs `make test` (all tests) after your work.** Validate only by a clean import of the new test module. Never perform any git write operation.
## Obey the rules you enforce
No comments or docstrings in source you author; no em-dashes (use a hyphen); keep `retoor <retoor@molodetz.nl>` as the first line of any file you create.
## Your dimension
Keep integration-test coverage in step with the routes and features, writing tests that follow the project's required patterns.
Keep integration-test coverage in step with the routes and features. The DevPlace suite is a hard project standard, not a nicety: one test file per endpoint, ~932 tests, split into three tiers with the directory tree mirroring the URL/source path. A route or feature that exercises a tier with no test in it is a coverage gap.
DETECT: routes and features with no corresponding test under `tests/{api,e2e,unit}/<endpoint-path>.py` (per the directory-mirrors-path naming rule). The project prefers integration tests over unit tests and tests the interface and API.
The three tiers and which one a change belongs to (decided by what it exercises, mirroring the existing files):
- **`tests/unit/`** - pure in-process tests of library functions (`local_db` or no fixture); the path mirrors the SOURCE module (`devplacepy/utils.py` -> `tests/unit/utils.py`, `devplacepy/services/audit/store.py` -> `tests/unit/services/audit/store.py`). The right tier for a new data/query/serialization helper.
- **`tests/api/`** - HTTP integration tests against the live uvicorn subprocess (`app_server`/`seeded_db`, `requests`/`httpx` vs `BASE_URL`, no browser); the path mirrors the endpoint (`POST /auth/login` -> `tests/api/auth/login.py`). The right tier for a JSON or HTML route, auth/role gating, and Devii actions.
- **`tests/e2e/`** - Playwright browser tests (`page`/`alice`/`bob`); the path mirrors the endpoint (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`). The right tier for an interactive UI flow. The project prefers the interface/API tiers over unit where either fits.
FIX: write the missing integration test following the required patterns: every `page.goto` and `page.wait_for_url` passes `wait_until="domcontentloaded"`; selectors are scoped; a test that flips a global `site_settings` value restores it in `try/finally`; the shared fixtures (`alice`, `bob`, `app_server`) are used; test functions are `test_`-prefixed though files are not.
A feature that adds a data helper AND a JSON route AND a UI flow needs a test in all three tiers. Choose the tier(s) by what the change actually touches; never leave a new route or helper untested.
DETECT: routes, features, data helpers, and Devii actions with no corresponding test in the tier(s) they exercise under `tests/{unit,api,e2e}/<path>.py` (per the directory-mirrors-path naming rule). A collection path that also parents deeper paths uses `index.py` in its own directory.
FIX: write the missing test in the correct tier, creating any missing package directories (`__init__.py`), following the required patterns: every `page.goto` and `page.wait_for_url` passes `wait_until="domcontentloaded"`; selectors are scoped; a test that flips a global `site_settings` value restores it in `try/finally`; the shared fixtures (`alice`, `bob`, `app_server`, `seeded_db`) are used; test functions are `test_`-prefixed though files are not; a raw insert into a `SOFT_DELETE_TABLES` table sets `deleted_at`/`deleted_by`; a test that mutates a cross-process cached value (settings/roles) polls the endpoint rather than asserting immediately.
## Scope units
- **coverage-gaps**: `routers/*.py` routes with no referencing test under `tests/{api,e2e,unit}/`.
- **pattern-lint**: `tests/*.py` use `domcontentloaded`, scoped selectors, try/finally global restore, shared fixtures.
- **coverage-gaps**: `routers/*.py` routes, `database.py`/service data helpers, and `services/devii/actions/catalog.py` actions with no referencing test in the tier(s) they exercise under `tests/{unit,api,e2e}/`.
- **tier-fit**: a feature exercising a tier (a UI flow with only an api test, a data helper with no unit test) where that tier's test is missing.
- **pattern-lint**: `tests/*.py` use `domcontentloaded`, scoped selectors, try/finally global restore, shared fixtures, born-live soft-delete inserts.
## Output
Return a markdown report: a one-line summary line, then one bullet per finding with severity (`error`/`warning`/`info`), `file:line`, the rule name, the message, and (in fix mode) whether the test was written.
+34
View File
@@ -0,0 +1,34 @@
---
description: WCAG 2.2 AA+ accessibility specialist - audit and upgrade the entire site for blind users with semantic HTML and ARIA, section by section.
allowed-tools: Read, Grep, Glob, Edit, Write, Bash
---
You are now an expert WCAG 2.2 AA+ accessibility specialist with deep screen reader experience (NVDA, JAWS, VoiceOver, TalkBack). Your task is to upgrade the ENTIRE website for blind users using proper ARIA attributes, semantic HTML, and best practices. Do this comprehensively and leave nothing out.
Project rules:
- Audit and improve EVERY page, component, modal, dynamic element, form, navigation, interactive widget, data table, tab system, accordion, carousel, live region, etc.
- Prioritize semantic HTML first (proper <nav>, <main>, <section>, <article>, <button>, <header>, etc.), then enhance with ARIA where needed.
- Apply ARIA roles, states, properties, and relationships rigorously: aria-label, aria-labelledby, aria-describedby, aria-expanded, aria-hidden, aria-live, aria-atomic, aria-relevant, aria-controls, aria-current, aria-haspopup, aria-modal, role="dialog", role="alertdialog", role="tabpanel", role="tablist", role="tab", role="menuitem", role="tree", role="grid", etc.
- Make all interactive elements fully keyboard accessible and announceable.
- Handle dynamic content (JavaScript-updated sections, infinite scroll, single-page app behavior, React/Vue/Svelte/Angular/Alpine/etc. components) with proper live regions and ARIA updates.
- Ensure landmark regions are correctly defined and unique.
- Fix color contrast, focus management, focus traps, skip links, and screen reader-only content where relevant.
- Provide both the updated code and clear before/after explanations for every major change.
Workflow you MUST follow:
1. Ask me for the full codebase structure (or the specific files/folders I want processed first). I will provide HTML, JSX, TSX, templates, CSS, or component code.
2. Process the site systematically: start with global layout (header, nav, footer, main), then all major pages/sections, then all reusable components.
3. For each file or component you receive, output:
- A summary of accessibility issues found.
- The complete rewritten/improved code with all ARIA added.
- Detailed comments explaining every ARIA addition.
- Any additional recommendations (e.g., CSS for focus styles, JavaScript patterns for dynamic ARIA).
4. After finishing a section, ask for the next part until the entire site is covered. Do not stop until I confirm the whole site is done.
Strict requirements:
- Never use ARIA when native HTML elements already provide the semantics.
- Follow ARIA Authoring Practices Guide (APG) strictly.
- Ensure the site remains fully functional and visually unchanged unless accessibility requires minor tweaks.
- Aim for WCAG 2.2 Level AA compliance or better, with extra care for Level AAA where feasible for blind users.
- Think like a blind power user: every action, state change, and piece of information must be perfectly announced and navigable.
Start by asking for the entry point (e.g. index.html, main layout file, or the list of main pages/components). Then proceed file-by-file or section-by-section until the entire site is upgraded. Be extremely thorough - literally upgrade the whole site.
+1 -1
View File
@@ -12,4 +12,4 @@ Follow the audit-log design (`devplacepy/services/audit/`); confirm against the
3. Call the recorder on the mutation's success path: `audit.record(request, event_key, ...)` in HTTP or WebSocket handlers, or `audit.record_system(event_key, ...)` in request-less contexts (services, jobs, CLI). On a guard or denial branch pass `result="denied"`; on a failure branch pass `result="failure"`.
4. Route through the existing DRY choke point when one applies (`content.py`, the `project_files.py` helpers, `routers/containers.py` `_audit_instance`, the Devii dispatcher `_audit_mechanic`) instead of scattering call sites. The HTTP path and the Devii path for one mutation must stay disjoint (no double counting).
5. Recording is best-effort: wrap nothing the caller depends on, and NEVER gate the audited action on the record succeeding.
6. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.
6. Validate the touched files with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and `python -c "from devplacepy.main import app"`.
+1 -1
View File
@@ -10,4 +10,4 @@ Follow the docs convention exactly (confirm against `devplacepy/routers/docs/pag
1. Create `devplacepy/templates/docs/<slug>.html` as a prose page: one `<div class="docs-content" data-render> ... </div>` containing GitHub-flavored markdown. The page is rendered server-side. Any example component markup INSIDE the data-render block must be HTML-escaped (`&lt;dp-...&gt;`); a live demo, if any, goes in a SEPARATE block OUTSIDE the data-render div with its own `<script type="module">`.
2. Register it in `DOCS_PAGES` in `devplacepy/routers/docs/pages.py`: `{"slug": "<slug>", "title": "<title>", "kind": "prose", "section": SECTION_*}`. Add `"admin": True` for an admin-only page. If a new section is needed, add a `SECTION_*` constant and place it in the correct `AUDIENCES` group.
3. Write accurate, professional content - confirm every factual claim against the source. No em-dashes, no AI disclaimers, dates as DD/MM/YYYY.
4. Validate: run `hawk` on the new template and on `pages.py`, run `python -c "from devplacepy.main import app"`, and confirm the slug is registered with no duplicate.
4. Validate: check the new template for tag and `{% %}` balance and `pages.py` with `python -m py_compile` + `pyflakes`, run `python -c "from devplacepy.main import app"`, and confirm the slug is registered with no duplicate.
+3 -3
View File
@@ -1,5 +1,5 @@
---
description: Explain a DevPlace subsystem, route, or file - read the relevant AGENTS.md section and the code, then summarize architecture, data flow, invariants, and entry points. Read-only.
description: Explain a DevPlace subsystem, route, or file - read the relevant nested CLAUDE.md and the code, then summarize architecture, data flow, invariants, and entry points. Read-only.
argument-hint: <area, route, or file>
allowed-tools: Read, Grep, Glob, Bash(git log:*)
---
@@ -8,13 +8,13 @@ Orient me on: **$ARGUMENTS**
Investigate before explaining; confirm every claim against the source.
1. Locate the code: the router under `devplacepy/routers/`, the template under `devplacepy/templates/`, data helpers in `devplacepy/database.py`, schemas in `devplacepy/schemas.py`, and any service under `devplacepy/services/`.
2. Read the matching domain section in `AGENTS.md` (the long-form companion) and the relevant part of `CLAUDE.md`.
2. Read the matching nested `CLAUDE.md` for the subsystem (e.g. `devplacepy/services/devii/CLAUDE.md`), plus the relevant cross-cutting part of the root `CLAUDE.md`.
3. Trace the data flow: input model (`models.py`) -> router handler + guard -> data helper -> response (HTML via `respond` + template, JSON via the `*Out` schema), plus the Devii action (`catalog.py`) and API docs (`docs_api.py`) where present.
Then give a tight explanation:
- What it does and where it lives, with `file:line` references.
- The request pipeline and data flow.
- Key invariants and gotchas (pull these from AGENTS.md).
- Key invariants and gotchas (pull these from the nested CLAUDE.md).
- The fan-out: which of the nine feature layers exist for it.
Do not modify anything.
+8 -6
View File
@@ -1,14 +1,14 @@
---
description: Run the DevPlace maintenance agent fleet (10 quality dimensions) in check or fix mode, optionally scoped to changed files or a subset.
description: Run the DevPlace maintenance agent fleet (12 quality dimensions) in check or fix mode, optionally scoped to changed files or a subset.
argument-hint: "[check|fix] [changed] [comma,list,of,dimensions]"
---
You are orchestrating the DevPlace maintenance fleet. Each dimension is a project subagent under `.claude/agents/`. The fleet enforces ten independent quality dimensions across the `devplacepy/` package and `tests/`.
You are orchestrating the DevPlace maintenance fleet. Each dimension is a project subagent under `.claude/agents/`. The fleet enforces twelve independent quality dimensions across the `devplacepy/` package and `tests/`.
## Dimension to subagent map
| Dimension | Subagent | Enforces |
|-----------|----------|----------|
| style | `style-maintainer` | CLAUDE.md/AGENTS.md coding rules (context-aware names, em-dash, typing, pathlib, headers) |
| style | `style-maintainer` | CLAUDE.md (root/nested) coding rules (context-aware names, em-dash, typing, pathlib, headers) |
| dry | `dry-maintainer` | duplication and reuse of canonical shared utilities |
| security | `security-maintainer` | auth guards, project visibility, read-only guards, input validation, XSS |
| audit | `audit-maintainer` | audit-log coverage and event catalogue |
@@ -18,20 +18,22 @@ You are orchestrating the DevPlace maintenance fleet. Each dimension is a projec
| fanout | `fanout-maintainer` | cross-layer feature completeness |
| docs | `docs-maintainer` | docs coverage and role-aware show/hide |
| test | `test-maintainer` | integration-test coverage |
| background | `background-maintainer` | background-queue deferral, response-critical/inline boundaries |
| locust | `locust-maintainer` | locustfile.py route coverage and load-test safety |
The canonical run order is: **style, dry, security, audit, devii, seo, frontend, fanout, docs, test**.
The canonical run order is: **style, dry, security, audit, devii, seo, frontend, fanout, docs, test, background, locust**.
## Parse the arguments
Arguments: `$ARGUMENTS`
- **Mode**: `fix` anywhere in the arguments means FIX mode; otherwise default to CHECK mode (read-only report).
- **changed**: the word `changed` means scope the run to only the files git reports as modified or new under `devplacepy/` and `tests/`. Compute that set first with `git status --porcelain` and keep existing paths whose first segment is `devplacepy/` or `tests/`. If the set is empty, report "nothing to do" and stop. Pass the explicit file list into each subagent's prompt so it reports/fixes only within that set (it may still read other files for cross-reference).
- **Subset**: any comma-separated dimension names (e.g. `security,docs`) restrict the run to those dimensions in canonical order. With no subset, run all ten.
- **Subset**: any comma-separated dimension names (e.g. `security,docs`) restrict the run to those dimensions in canonical order. With no subset, run all twelve.
## Execute
1. Resolve the dimension list and mode from the arguments above.
2. **CHECK mode**: launch every selected subagent concurrently (one `Agent` call per dimension in a single message). Each subagent runs read-only and returns its findings report. Tell each subagent explicitly: "Operate in REPORT mode. Do not modify any file." If `changed`, append the file list and: "Restrict findings to these files."
3. **FIX mode**: launch the selected subagents **one at a time in canonical order** (never in parallel - parallel edits to the same file would conflict). Tell each: "Operate in FIX mode: apply minimal root-cause fixes per your doctrine, then run `hawk .` and confirm it passes." Wait for each to finish before starting the next. If `changed`, append the file list and: "Restrict fixes to these files."
3. **FIX mode**: launch the selected subagents **one at a time in canonical order** (never in parallel - parallel edits to the same file would conflict). Tell each: "Operate in FIX mode: apply minimal root-cause fixes per your doctrine, then re-validate every file you touched with the per-language checks and confirm `python -c \"from devplacepy.main import app\"` still imports clean." Wait for each to finish before starting the next. If `changed`, append the file list and: "Restrict fixes to these files."
4. Each subagent's final message is its report; it is not shown to the user directly, so collect them.
## Report
+2 -2
View File
@@ -12,5 +12,5 @@ Mirror an existing service - read `devplacepy/services/base.py` (BaseService) an
3. Register it in `main.py` startup: `service_manager.register(YourService())`, under the same `DEVPLACE_DISABLE_SERVICES` guard as the others. It then auto-appears on `/admin/services`.
4. If it calls an LLM, default its endpoint to `config.INTERNAL_GATEWAY_URL` and authenticate with the internal gateway key, like the other AI consumers.
5. Emit audit events via `record_system` for any state change it makes.
6. Document it in `AGENTS.md` (Background services section) and in `README.md` if user-visible.
7. Validate with `hawk` on the touched files and `python -c "from devplacepy.main import app"`.
6. Document it in `devplacepy/services/CLAUDE.md` (Background services base machinery section, or the service's own nested `CLAUDE.md` if it has one) and in `README.md` if user-visible.
7. Validate the touched files with the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates) and `python -c "from devplacepy.main import app"`.
+2 -2
View File
@@ -1,5 +1,5 @@
---
description: Run DevPlace tests - the sanctioned explicit-ask path. Run a tier, a file, or a single test with the correct flags. The agents never run tests themselves; this command is how you ask.
description: Run DevPlace tests. Run a tier, a file, or a single test with the correct flags. Subagents never run tests themselves (the serial suite cannot run concurrently); the orchestrating session always runs the full suite as the final validation of every change.
argument-hint: [unit|api|e2e|all|<path::test_name>]
allowed-tools: Bash(make test*), Bash(python -m pytest *), Read
---
@@ -12,6 +12,6 @@ Mapping:
- `all` or empty -> `make test`
- a path like `tests/api/posts/create.py::test_x` -> `python -m pytest <that> -v --tb=line -x`
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. This command is the one sanctioned way to run them (the subagents and workflows never do).
Tests run serially on port 10501 with a tempfile SQLite DB and `DEVPLACE_DISABLE_SERVICES=1`. Subagents and workflows never launch tests (the serial single-process suite cannot run concurrently); the orchestrating session runs the full suite (`make test`) as the mandatory final validation of every change.
Report results clearly. On a failure, show the relevant output, and if a browser (e2e) test failed, point me at the screenshot under `/tmp/devplace_test_screenshots/`. Never weaken a test to make it pass; if a test reveals a real bug, report it - do not edit the test.
+3 -3
View File
@@ -1,13 +1,13 @@
---
description: Run the mandatory DevPlace pre-completion verification on changed files - the validator, the app import, and an em-dash scan. Zero errors required. Never runs the test suite.
allowed-tools: Bash(python *), Bash(hawk *), Bash(git status:*), Bash(git diff:*), Read, Grep
description: Run the mandatory DevPlace pre-completion verification on changed files - the per-language checks, the app import, and an em-dash scan. Zero errors required. Never runs the test suite.
allowed-tools: Bash(python *), Bash(node *), Bash(git status:*), Bash(git diff:*), Read, Grep
---
Changed files in the working tree:
!`git status --porcelain`
Verify the work is complete and correct, following the DevPlace verification rule (zero tolerance):
1. For each changed or new file under `devplacepy/` or `tests/`, run `hawk <file>` (it covers Python, JavaScript, CSS, and HTML/Jinja). Every file must report clean.
1. For each changed or new file under `devplacepy/` or `tests/`, run the per-language checks (`python -m py_compile` + `pyflakes` for Python, `node --check` for JS, brace balance for CSS, tag and `{% %}` balance for templates). Every file must come back clean.
2. Run `python -c "from devplacepy.main import app"` - it must import with no error.
3. Grep the changed files for em-dash characters (U+2014 and U+2013) that are authored prose, and report any. Leave em-dashes that are data (replace/maketrans/regex targets, fixtures) untouched.
4. Report a PASS or FAIL summary with the exact failures.
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>
import json
import re
import sys
from pathlib import Path
CONFIRMATION_TOKEN = "I-HAVE-CONFIRMED-PRODUCTION-DB-ACCESS"
PRODUCTION_PATHS = re.compile(
r"data/(devplace\.db|devii_tasks\.db|devii_lessons\.db|keys|uploads"
r"|attachments|project_files|backups)\b"
)
PRODUCTION_DB_FILE = re.compile(r"\bdevplace\.db\b")
MANAGEMENT_CLI = re.compile(r"(?:^|[;&|(]\s*|\s)(?:[\w./-]*/)?devplace\s+(?!-)")
PYTHON_INVOCATION = re.compile(r"(?:^|[;&|(\s])(?:[\w./-]*/)?python[0-9.]*(?:\s|$)")
DATABASE_OVERRIDE = re.compile(r"DEVPLACE_DATABASE_URL\s*=\s*[\"']?(\S+?)[\"']?(?:\s|$)")
DATABASE_ASSIGNMENT = re.compile(r"DEVPLACE_DATABASE_URL[\"'\]\s]*[=,]")
MODULE_INVOCATION = re.compile(r"-m\s+devplacepy")
INLINE_CODE = re.compile(r"-c\s+(?P<quote>[\"'])(?P<code>.*?)(?P=quote)", re.DOTALL)
SCRIPT_PATH = re.compile(r"(?:^|\s)(?P<path>[\w./~-]+\.py)(?:\s|$)")
IMPORT_GATE = re.compile(
r"^from devplacepy\.main import app\s*;?\s*(?:print\([^)]*\)\s*;?\s*)?$"
)
TEST_RUNNER = re.compile(r"\bpytest\b|\bmake\s+(test|test-[\w-]+)\b")
SERVER_TARGET = re.compile(r"\bmake\s+(dev|prod|docker-[\w-]+|ppy)\b")
APPLICATION_IMPORT = re.compile(
r"(?:^|[\s;])(?:from|import)\s+devplacepy\b"
r"|import_module\s*\(\s*[\"']devplacepy",
re.MULTILINE,
)
def reaches_application(text: str) -> bool:
return bool(APPLICATION_IMPORT.search(text))
def overrides_the_database(text: str) -> bool:
match = DATABASE_OVERRIDE.search(text)
if not match:
return False
return "data/devplace.db" not in match.group(1)
def source_targets_a_scratch_database(source: str) -> bool:
if PRODUCTION_DB_FILE.search(source):
return False
return bool(DATABASE_ASSIGNMENT.search(source))
def script_is_safe(command: str) -> bool | None:
match = SCRIPT_PATH.search(command)
if not match:
return None
path = Path(match.group("path")).expanduser()
try:
body = path.read_text(errors="replace")
except OSError:
return None
if not reaches_application(body):
return True
return source_targets_a_scratch_database(body)
def hazard_in(command: str) -> str:
if CONFIRMATION_TOKEN in command:
return ""
if TEST_RUNNER.search(command) or SERVER_TARGET.search(command):
return ""
if PRODUCTION_DB_FILE.search(command) or PRODUCTION_PATHS.search(command):
return "it names the production database or a production data directory"
if MANAGEMENT_CLI.search(command):
return "the devplace management CLI operates on the production database"
if not PYTHON_INVOCATION.search(command):
return ""
if overrides_the_database(command):
return ""
if MODULE_INVOCATION.search(command):
return "it runs a devplacepy module with no DEVPLACE_DATABASE_URL override"
inline = INLINE_CODE.search(command)
if inline:
code = inline.group("code").strip()
if not reaches_application(code):
return ""
if IMPORT_GATE.match(code):
return ""
return "it imports devplacepy inline with no DEVPLACE_DATABASE_URL override"
safe = script_is_safe(command)
if safe is None:
return ""
if safe:
return ""
return "the script imports devplacepy with no DEVPLACE_DATABASE_URL override"
def refuse(reason: str) -> dict:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Blocked: this command reaches the production database because {reason}. "
"The production database is never touched without the user's explicit, "
"stated confirmation. Stop, tell the user exactly what the command would "
"read or write, and ask them to confirm in their own words. Only after "
f"they have done so may the command carry the literal token "
f"{CONFIRMATION_TOKEN}, which still raises a permission prompt they must "
"approve. Never add that token on your own initiative. Alternatives that "
"need no confirmation: set DEVPLACE_DATABASE_URL to a scratch database, "
"or run the test suite."
),
}
}
def confirm(reason: str) -> dict:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": (
"This command carries the production-database confirmation token and "
f"reaches the production database because {reason}. Approve only if you "
"asked for this."
),
}
}
def main() -> None:
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
sys.exit(0)
command = (payload.get("tool_input") or {}).get("command") or ""
if not command:
sys.exit(0)
if CONFIRMATION_TOKEN in command:
stripped = command.replace(CONFIRMATION_TOKEN, "")
reason = hazard_in(stripped)
if reason:
print(json.dumps(confirm(reason)))
sys.exit(0)
reason = hazard_in(command)
if reason:
print(json.dumps(refuse(reason)))
sys.exit(0)
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"deny": [
"Bash(devplace *)",
"Write(data/**)",
"Edit(data/**)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_production_db.py\"",
"timeout": 10,
"statusMessage": "Checking for production database access"
}
]
}
]
}
}
+15 -7
View File
@@ -1,12 +1,13 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'devii-tool',
description: 'Add a Devii agent capability: an Action in the catalog with auth flags matched to the route guard, dispatcher wiring, API docs, and a test, then verify role-gating and confirmation',
description: 'Add a Devii agent capability: an Action in the catalog with auth flags matched to the route guard, dispatcher wiring, API docs, then verify role-gating and confirmation and write the api-tier integration test for the action',
phases: [
{ title: 'Understand', detail: 'find the underlying route and a similar Action to mirror' },
{ title: 'Implement', detail: 'add the Action, wire the handler, document it' },
{ title: 'Verify', detail: 'role-gating, flag alignment, and confirm gating' },
{ title: 'Fix', detail: 'close gaps and write the action test' },
{ title: 'Fix', detail: 'close gaps from the review' },
{ title: 'Test', detail: 'write the api-tier integration test for the action (visibility, auth gating, confirm)' },
],
}
@@ -17,7 +18,14 @@ const RULES = [
'- A Devii Action requires_auth/requires_admin MUST exactly match the underlying route guard. Never grant a member an admin capability. A non-admin must not even see an admin tool schema.',
'- If the action is irreversible or destructive, add it to dispatcher CONFIRM_REQUIRED and declare a confirm boolean param in its spec (schemas set additionalProperties:false, so a gated tool without a declared confirm param can never receive confirm=true and loops forever).',
'- Prefer handler="http" reusing an existing REST route; only add a local controller handler when there is no route. Reuse the arg()/body()/query()/confirm() helpers for params.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement - one test file per endpoint, the directory tree mirroring the URL path):',
'A Devii tool is reached over the same HTTP surface a user hits, so its test lives in tests/api/ (often tests/api/devii/), against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL).',
'Cover the role-gating that is the whole point of the tool: an unauthenticated/guest caller is refused, a member sees and can call a requires_auth tool but is refused a requires_admin one (and its schema is withheld), an admin can call it, and a destructive action is refused without confirm=true and proceeds with it.',
'Required patterns: scoped assertions; try/finally restore of any flipped global setting; the shared fixtures (alice, bob, app_server); test FUNCTIONS are test_-prefixed though files are not. Validate by a clean import only. NEVER run the suite.',
].join('\n')
function toolBrief() {
@@ -92,7 +100,7 @@ const map = await agent(
)
const build = await agent(
`Add this Devii tool, editing files directly in the repo. Add the Action to the catalog mirroring the similar action, set requires_auth/requires_admin to exactly match the underlying route guard, wire the dispatcher handler if a new local handler is needed, and add a docs_api.py entry if it wraps an HTTP endpoint. If destructive, add it to CONFIRM_REQUIRED and declare a confirm param. Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nTool request: ${ask}\n\nContext:\n${JSON.stringify(map, null, 2)}\n\n${RULES}\n\nReturn the action name, files changed, and whether validator and import passed.`,
`Add this Devii tool, editing files directly in the repo. Add the Action to the catalog mirroring the similar action, set requires_auth/requires_admin to exactly match the underlying route guard, wire the dispatcher handler if a new local handler is needed, and add a docs_api.py entry if it wraps an HTTP endpoint. If destructive, add it to CONFIRM_REQUIRED and declare a confirm param. Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nTool request: ${ask}\n\nContext:\n${JSON.stringify(map, null, 2)}\n\n${RULES}\n\nReturn the action name, files changed, and whether the checks and the import passed.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
@@ -119,14 +127,14 @@ const gaps = audits
let gapFix = 'no actionable gaps'
if (gaps.length) {
gapFix = await agent(
`Close these Devii tool gaps with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
`Close these Devii tool gaps with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
)
}
const test = await agent(
`Operate in FIX mode. Write the integration test for this Devii tool following the required patterns (tests/api/devii layout). Validate by a clean import only. NEVER run the suite.\n\nTool request: ${ask}\nAction: ${build && build.actionName}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Fix' }
`Operate in FIX mode. Write the integration test for this Devii tool following the required patterns (tests/api/devii layout), asserting the role-gating and confirm behavior described below. The tool is not complete until its gating is tested. Create any missing package directories the test path needs. Validate by a clean import only. NEVER run the suite.\n\n${TESTS}\n\nTool request: ${ask}\nAction: ${build && build.actionName}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test file written and the gating cases it covers.`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Test' }
)
return { ask, map, build, audit: gaps, gapFix, test }
+16 -7
View File
@@ -1,12 +1,13 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'endpoint',
description: 'Scaffold ONE new DevPlace route across all of its touchpoints (Form model, Out schema, guarded handler with respond, main.py mount, template, Devii action, API docs, SEO, test) and verify it',
description: 'Scaffold ONE new DevPlace route across all of its touchpoints (Form model, Out schema, guarded handler with respond, main.py mount, template, Devii action, API docs, SEO) and verify it, then write its integration test in the matching tier (api for JSON/HTML, e2e for an interactive UI flow)',
phases: [
{ title: 'Understand', detail: 'find the closest existing route to mirror' },
{ title: 'Implement', detail: 'wire the route across every touchpoint' },
{ title: 'Verify', detail: 'completeness and security review of the new route' },
{ title: 'Fix', detail: 'close gaps and write the route test' },
{ title: 'Fix', detail: 'close gaps from the review' },
{ title: 'Test', detail: 'write the route test in the matching tier (api or e2e), mirroring the path' },
],
}
@@ -16,7 +17,7 @@ const RULES = [
'- No em-dash characters; use a hyphen. Full Python type hints; pathlib over os; Pydantic Form input with explicit max lengths; sanitize and bound user input.',
'- Reuse templating.templates, database.py batch helpers, respond(), the shared partials and frontend utilities. Never per-router Jinja2Templates.',
'- Guards: get_current_user (public read), require_user (member write), require_admin (admin). Every POST/PUT/DELETE is guarded. Declare specific routes before catch-alls. Never pass a respond() context key that collides with a Jinja global (use viewer_is_admin).',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const TOUCHPOINTS = [
@@ -31,6 +32,14 @@ const TOUCHPOINTS = [
'8. seo.py - base_seo_context for a public page; sitemap entry if indexable.',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement - one test file per endpoint, the directory tree mirroring the URL path):',
'- tests/api/ - HTTP integration test against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL) - the right tier for a JSON or HTML route.',
'- tests/e2e/ - Playwright browser test (page/alice/bob) - the right tier for an interactive UI flow.',
'The route path maps to the test path by dropping {param} segments and lowercasing each segment (POST /auth/login -> tests/api/auth/login.py; GET /admin/ai-usage -> tests/e2e/admin/aiusage.py). A collection path that also parents deeper paths uses index.py in its own directory. Create any missing package directories with __init__.py.',
'Required patterns: wait_until="domcontentloaded" on every goto/wait_for_url; scoped selectors; try/finally restore of any flipped global setting; the shared fixtures; test FUNCTIONS are test_-prefixed though files are not. Validate by a clean import only. NEVER run the suite.',
].join('\n')
function endpointBrief() {
if (!args) return ''
if (typeof args === 'string') return args
@@ -99,7 +108,7 @@ const map = await agent(
)
const build = await agent(
`Implement this single DevPlace route across every applicable touchpoint, editing files directly in the repo, mirroring the closest existing route. Keep the layers in agreement (Out schema carries every returned JSON key; Devii action auth flags match the guard). Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nEndpoint: ${ask}\n\nClosest route to mirror:\n${JSON.stringify(map, null, 2)}\n\n${TOUCHPOINTS}\n\n${RULES}\n\nReturn the files changed and whether validator and import passed.`,
`Implement this single DevPlace route across every applicable touchpoint, editing files directly in the repo, mirroring the closest existing route. Keep the layers in agreement (Out schema carries every returned JSON key; Devii action auth flags match the guard). Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write the test here. Do not run the suite. Do not commit.\n\nEndpoint: ${ask}\n\nClosest route to mirror:\n${JSON.stringify(map, null, 2)}\n\n${TOUCHPOINTS}\n\n${RULES}\n\nReturn the files changed and whether the checks and the import passed.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
@@ -126,14 +135,14 @@ const gaps = audits
let gapFix = 'no actionable gaps'
if (gaps.length) {
gapFix = await agent(
`Close these gaps on the new route with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
`Close these gaps on the new route with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
)
}
const test = await agent(
`Operate in FIX mode. Write the integration test for this new route following the required patterns and the directory-mirrors-path layout. Validate by a clean import only. NEVER run the suite.\n\nEndpoint: ${ask}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Fix' }
`Operate in FIX mode. Write the integration test for this new route in the matching tier (api for a JSON/HTML route, e2e for an interactive UI flow) following the required patterns and the directory-mirrors-path layout. The route is not complete until it has a test. Create any missing package directories the test path needs. Validate by a clean import only. NEVER run the suite.\n\n${TESTS}\n\nEndpoint: ${ask}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test file written and its tier.`,
{ agentType: 'test-maintainer', label: 'test', phase: 'Test' }
)
return { ask, map, build, audit: gaps, gapFix, test }
+150 -28
View File
@@ -1,13 +1,15 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'feature',
description: 'Add a feature across the full DevPlace fan-out: understand the area, plan the layers, implement coherently in the repo, audit completeness and security, fix gaps and write tests, then verify',
description: 'Add a feature across the full DevPlace fan-out: understand the area, plan the layers, build via the feature-builder agent, audit every quality dimension with adversarial verification, verify live in the browser and over HTTP, close gaps, then write the integration tests across every applicable tier (unit, api, e2e)',
phases: [
{ title: 'Understand', detail: 'map the target area and a similar existing feature' },
{ title: 'Plan', detail: 'a per-layer implementation plan across the nine touchpoints' },
{ title: 'Implement', detail: 'build all layers coherently in the repo' },
{ title: 'Audit', detail: 'completeness and security review of the changed files' },
{ title: 'Fix', detail: 'close audit gaps and write missing integration tests' },
{ title: 'Implement', detail: 'build all layers coherently via the feature-builder agent' },
{ title: 'Audit', detail: 'every relevant quality dimension, each finding adversarially verified against source' },
{ title: 'Verify', detail: 'live dev-server visual (falcon) and API (hound) verification of the change' },
{ title: 'Fix', detail: 'close confirmed gaps from the audit and live verification' },
{ title: 'Test', detail: 'write integration tests across every applicable tier (unit, api, e2e), one file per endpoint mirroring the path' },
],
}
@@ -17,10 +19,10 @@ const RULES = [
'- First line of any NEW file is the header: Python "# retoor <retoor@molodetz.nl>", JS "// retoor <retoor@molodetz.nl>", CSS "/* retoor <retoor@molodetz.nl> */".',
'- No em-dash characters; use a hyphen. Source is English only.',
'- Full type hints on Python signatures and variables; pathlib over os; Pydantic Form input with explicit max lengths; sanitize and bound all user input.',
'- Reuse shared helpers: templating.templates (never a per-router Jinja2Templates), database.py batch helpers (no inline N+1), the respond() negotiator, _avatar_link.html / _user_link.html, and on the frontend Http / Poller / JobPoller / OptimisticAction / FloatingWindow.',
'- Reuse shared helpers: templating.templates (never a per-router Jinja2Templates), database.py batch helpers (no inline N+1), the respond() negotiator, _avatar_link.html / _user_link.html, and on the frontend Http / Poller / JobPoller / OptimisticAction / FloatingWindow and the dp-* components.',
'- Auth guards: get_current_user (public read), require_user (member write), require_admin (admin). Every POST/PUT/DELETE is guarded; deletes are soft and owner-or-admin.',
'- Never pass a respond() context key that collides with a Jinja global (use viewer_is_admin, not is_admin). Dates are DD/MM/YYYY via format_date.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the pytest suite. Never perform any git write.',
].join('\n')
const FANOUT = [
@@ -33,7 +35,17 @@ const FANOUT = [
'6. services/devii/actions/catalog.py - an Action(name, method, path, summary, params, requires_auth, requires_admin) if a user could ask Devii to do it; a confirm param plus membership in CONFIRM_REQUIRED if destructive.',
'7. docs_api.py - an endpoint() entry in the right group with params and sample_response for every public or authenticated route.',
'8. seo.py - base_seo_context(request, ...) merged into the context for public pages; a sitemap entry in routers/seo.py if indexable.',
'9. README.md (product) + AGENTS.md (mechanics) + CLAUDE.md (only for a genuinely new architectural rule).',
'9. README.md (product) + the relevant nested CLAUDE.md (mechanics) + the root CLAUDE.md (only for a genuinely new architectural rule).',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement, NOT optional - the suite is one test file per endpoint, ~932 tests, with the directory tree mirroring the URL/source path):',
'- tests/unit/ - pure in-process tests of library functions (local_db or no fixture); the path mirrors the SOURCE module (devplacepy.utils -> tests/unit/utils.py).',
'- tests/api/ - HTTP integration tests against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL, no browser); the path mirrors the endpoint (POST /auth/login -> tests/api/auth/login.py).',
'- tests/e2e/ - Playwright browser tests (page/alice/bob); the path mirrors the endpoint (GET /admin/ai-usage -> tests/e2e/admin/aiusage.py).',
'A feature MUST get every tier it exercises: a new data/query helper -> a unit test; a new JSON or HTML route -> an api test; a new interactive UI flow -> an e2e test. Pick tiers by what the change actually touches; never ship a route or feature with no test in any tier.',
'Required patterns: every page.goto/page.wait_for_url passes wait_until="domcontentloaded"; selectors are scoped; a test that flips a global site_settings value restores it in try/finally; reuse the shared fixtures (alice, bob, app_server, seeded_db); test FUNCTIONS are test_-prefixed though files are not; raw inserts into a soft-delete table set deleted_at/deleted_by.',
'Validate each new test module by a clean import only (python -c "import ..." or python -m py_compile). NEVER run the suite, not the full suite and not one file - that is the human-only /test path.',
].join('\n')
function featureBrief() {
@@ -81,6 +93,7 @@ const PLAN_SCHEMA = {
},
},
},
routes: { type: 'array', items: { type: 'string' } },
outOfScope: { type: 'array', items: { type: 'string' } },
},
}
@@ -94,6 +107,7 @@ const BUILD_SCHEMA = {
filesChanged: { type: 'array', items: { type: 'string' } },
validatorPassed: { type: 'boolean' },
importOk: { type: 'boolean' },
routes: { type: 'array', items: { type: 'string' } },
notes: { type: 'string' },
},
}
@@ -122,24 +136,61 @@ const FINDINGS_SCHEMA = {
},
}
const VERDICT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['isReal', 'reason'],
properties: {
isReal: { type: 'boolean' },
reason: { type: 'string' },
severity: { type: 'string', enum: ['error', 'warning', 'info'] },
},
}
const LIVE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['ran', 'summary'],
properties: {
ran: { type: 'boolean' },
summary: { type: 'string' },
pagesChecked: { type: 'array', items: { type: 'string' } },
apiChecked: { type: 'array', items: { type: 'string' } },
issues: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['severity', 'where', 'message'],
properties: {
severity: { type: 'string', enum: ['error', 'warning', 'info'] },
where: { type: 'string' },
message: { type: 'string' },
},
},
},
},
}
log(`Feature: ${ask}`)
const map = await agent(
`Map the area of the DevPlace codebase relevant to this feature request, so it can be implemented. Read the closest existing feature end to end (its router, template, tests, and AGENTS.md section) as the pattern to follow. Do not write anything.\n\nFeature request: ${ask}\n\n${FANOUT}\n\nReturn: a summary of how this should be built, the concrete files to touch or create, the most similar existing feature to mirror, and any constraints.`,
`Map the area of the DevPlace codebase relevant to this feature request, so it can be implemented. Read the closest existing feature end to end (its router, template, tests, and the matching nested CLAUDE.md) as the pattern to follow. Do not write anything.\n\nFeature request: ${ask}\n\n${FANOUT}\n\nReturn: a summary of how this should be built, the concrete files to touch or create, the most similar existing feature to mirror, and any constraints.`,
{ agentType: 'Explore', label: 'understand', phase: 'Understand', schema: MAP_SCHEMA }
)
const plan = await agent(
`Produce a precise, per-layer implementation plan for this DevPlace feature. One step per file with the exact touchpoint to add or change. Mark layers that are intentionally not needed as outOfScope with a reason. Do not write code.\n\nFeature request: ${ask}\n\nArea map:\n${JSON.stringify(map, null, 2)}\n\n${FANOUT}`,
`Produce a precise, per-layer implementation plan for this DevPlace feature. One step per file with the exact touchpoint to add or change. List the user-facing routes (URL paths) the feature adds or changes in "routes". Mark layers that are intentionally not needed as outOfScope with a reason. Do not write code.\n\nFeature request: ${ask}\n\nArea map:\n${JSON.stringify(map, null, 2)}\n\n${FANOUT}`,
{ agentType: 'Plan', label: 'plan', phase: 'Plan', schema: PLAN_SCHEMA }
)
const build = await agent(
`Implement this DevPlace feature coherently and completely, editing files directly in the repo. Follow the plan; keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard). When done, run "hawk ." and "python -c \\"from devplacepy.main import app\\"" and report whether each passed. Do not write tests in this step. Do not run the test suite. Do not commit.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the validator and the import passed, and a short summary.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
`Implement directly - no plan, no approval needed, this is implement mode. Build this DevPlace feature coherently and completely, editing files in the repo, following the plan. Keep every layer in agreement (the *Out schema must carry every JSON key the handler returns; the Devii action auth flags must match the route guard; a respond() context key must never shadow a Jinja global). Do NOT write pytest tests in this step (a later phase owns that). When done, run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"" and report whether each passed, and list the user-facing routes the feature exposes.\n\nFeature request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${FANOUT}\n\n${RULES}\n\nReturn the list of files you changed or created, whether the checks and the import passed, the routes, and a short summary.`,
{ agentType: 'feature-builder', label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
const changed = (build && build.filesChanged) || []
const routes = (build && build.routes && build.routes.length ? build.routes : (plan && plan.routes) || [])
const scopeNote = changed.length
? `\n\nRestrict your findings to these changed files (read others only for cross-reference):\n${changed.join('\n')}`
: ''
@@ -147,36 +198,107 @@ const scopeNote = changed.length
const AUDITORS = [
{ key: 'fanout', agent: 'fanout-maintainer' },
{ key: 'security', agent: 'security-maintainer' },
{ key: 'style', agent: 'style-maintainer' },
{ key: 'dry', agent: 'dry-maintainer' },
{ key: 'frontend', agent: 'frontend-maintainer' },
{ key: 'seo', agent: 'seo-maintainer' },
{ key: 'audit', agent: 'audit-maintainer' },
{ key: 'devii', agent: 'devii-maintainer' },
{ key: 'docs', agent: 'docs-maintainer' },
]
const audits = await parallel(
AUDITORS.map((a) => () =>
agent(
`Operate in REPORT mode (read-only). Audit the just-implemented feature for your single dimension. Confirm each finding against the source.${scopeNote}\n\nFeature request: ${ask}`,
{ agentType: a.agent, label: `audit:${a.key}`, phase: 'Audit', schema: FINDINGS_SCHEMA }
).then((r) => ({ key: a.key, findings: (r && r.findings) || [] }))
function verifyPrompt(dimension, finding) {
return (
`Adversarially verify a candidate "${dimension}" finding against the just-built feature. Your goal is to REFUTE it. ` +
`Open the exact file and read enough surrounding context (the whole function, the caller, the contract) to judge intent. ` +
`It is REAL only if it survives refutation as a genuine violation of the ${dimension} dimension introduced by this change. ` +
`Rule it out (isReal=false) if it is a contract identifier, DATA rather than authored prose, generated/vendored/third-party, ` +
`pre-existing and untouched by this feature, or already correct under a known exemption. When uncertain, default to isReal=false.\n\n` +
`Candidate finding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n` +
`- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}\n\nReturn isReal and a one-line reason.`
)
}
const reviewed = await pipeline(
AUDITORS,
(auditor) =>
agent(
`Operate in REPORT mode (read-only). Do not modify any file. Audit the just-implemented feature for your single quality dimension, following your mandate and accuracy doctrine. Confirm each candidate against the actual source before recording it.${scopeNote}\n\nFeature request: ${ask}`,
{ agentType: auditor.agent, label: `audit:${auditor.key}`, phase: 'Audit', schema: FINDINGS_SCHEMA }
),
(review, auditor) =>
parallel(
((review && review.findings) || []).map((finding) => () =>
agent(verifyPrompt(auditor.key, finding), {
agentType: auditor.agent,
label: `verify:${auditor.key}`,
phase: 'Audit',
schema: VERDICT_SCHEMA,
}).then((verdict) => ({ ...finding, dimension: auditor.key, verdict }))
)
)
)
const gaps = audits
.filter(Boolean)
.flatMap((r) => r.findings.map((f) => ({ dimension: r.key, ...f })))
.filter((f) => f.severity !== 'info')
const auditCandidates = reviewed.flat().filter(Boolean)
const auditConfirmed = auditCandidates.filter((f) => f.verdict && f.verdict.isReal)
log(`Audit: ${auditConfirmed.length} confirmed of ${auditCandidates.length} candidate finding(s) across ${AUDITORS.length} dimensions`)
let gapFix = 'no actionable gaps from the audit'
const touchedFrontend = changed.some((f) => f.includes('/templates/') || f.includes('/static/'))
const touchedApi = changed.some((f) => f.includes('/routers/'))
let live = { ran: false, summary: 'no frontend or API files changed; live verification skipped', issues: [] }
if (touchedFrontend || touchedApi) {
const kinds = [touchedFrontend ? 'visual (falcon)' : null, touchedApi ? 'API (hound)' : null].filter(Boolean).join(' and ')
live = await agent(
`Operate the MANDATORY DevPlace live verification (${kinds}) for the just-built feature, exactly per CLAUDE.md.\n\n` +
`Procedure:\n` +
`1. Check if the dev server already answers: "mole check http://localhost:10500". If it does NOT, start it yourself with "make dev" as a BACKGROUND process, then poll "mole check http://localhost:10500" until healthy (give uvicorn a few seconds to boot). Remember whether YOU started it.\n` +
(touchedFrontend
? `2. VISUAL: for each user-facing route the feature adds or changes, capture a screenshot with the installed Playwright (chromium, headless) navigating to "http://localhost:10500<route>" with wait_until="domcontentloaded", saving a PNG under /tmp/, then run "falcon describe <png>". Compare each AI description against the intended UI and the surrounding design system (layout, spacing, design tokens, responsiveness). Record any mismatch, broken layout, missing element, or visual regression as an issue. Authenticated routes: log in via the /auth/login form first (seeded users may not exist on a fresh dev DB - if a route needs auth and you cannot reach it, record that as an info issue rather than failing).\n`
: '') +
(touchedApi
? `3. API: write a hound JSON spec (tests: name/method/path/expect_status[/expect_body/expect_headers]) covering the feature's endpoints with realistic expected statuses, then run "hound <spec>.json --base-url http://localhost:10500". Record every failing assertion as an issue.\n`
: '') +
`4. TEARDOWN: if YOU started the server, kill it now (do not leave a stray uvicorn running). If it was already running, leave it.\n\n` +
`Routes for this feature: ${routes.length ? routes.join(', ') : '(infer from the changed routers/templates below)'}\n` +
`Changed files:\n${changed.join('\n')}\n\n` +
`Return ran=true, the pages and api endpoints you checked, and one issue per real visual/functional defect (severity/where/message). Do not edit feature source in this phase; only report.`,
{ label: 'live-verify', phase: 'Verify', schema: LIVE_SCHEMA }
)
log(`Live verify: ${(live && live.issues && live.issues.length) || 0} issue(s) over ${((live && live.pagesChecked) || []).length} page(s)`)
}
const gaps = []
for (const f of auditConfirmed) {
if (f.severity !== 'info') gaps.push({ source: f.dimension, file: f.file, line: f.line, rule: f.rule, message: f.message })
}
for (const i of (live && live.issues) || []) {
if (i.severity !== 'info') gaps.push({ source: 'live-verify', file: i.where, rule: 'live', message: i.message })
}
let gapFix = 'no actionable gaps from the audit or live verification'
if (gaps.length) {
gapFix = await agent(
`Close these completeness and security gaps found by the audit of the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement. Re-run "hawk ." afterward. Do not run the test suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
`Close these confirmed completeness, security, style, frontend, and live-rendering gaps found in the new feature. Apply minimal root-cause fixes directly in the repo, keeping all layers in agreement and the styling consistent with the design system. Re-run the per-language checks afterward. Do not run the pytest suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ agentType: 'feature-builder', label: 'fix-gaps', phase: 'Fix' }
)
}
const tests = await agent(
`Operate in FIX mode. Write the missing integration tests for this new feature following the required Playwright patterns and the directory-mirrors-path layout. Validate each new test module by a clean import only. NEVER run the suite, not the full suite and not one file.\n\nFeature request: ${ask}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Fix' }
`Operate in FIX mode. Write the missing integration tests for this new feature across EVERY tier it exercises, per the DevPlace test standard below. This is mandatory, not a nicety: the feature is incomplete until each route and helper it adds has a test in the appropriate tier (unit for new data/query helpers, api for new JSON/HTML routes, e2e for new interactive UI flows), in the correct file under the directory-mirrors-path layout. Decide the tiers from the changed files and routes; create the package directories (with __init__.py) the new test paths require. Validate each new test module by a clean import only. NEVER run the suite, not the full suite and not one file.\n\n${TESTS}\n\nFeature request: ${ask}\nRoutes: ${routes.join(', ')}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test files you wrote, the tier of each, and which routes/helpers remain uncovered (with the reason).`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Test' }
)
log(`Feature build complete: ${changed.length} file(s), ${gaps.length} audit gap(s) addressed`)
log(`Feature build complete: ${changed.length} file(s), ${gaps.length} gap(s) addressed`)
return { ask, map, plan, build, audit: gaps, gapFix, tests }
return {
ask,
map,
plan,
build,
routes,
audit: { candidates: auditCandidates.length, confirmed: auditConfirmed, gaps },
liveVerify: live,
gapFix,
tests,
}
+5 -4
View File
@@ -1,9 +1,9 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'fleet',
description: 'DevPlace maintenance fleet: 10 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
description: 'DevPlace maintenance fleet: 12 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
phases: [
{ title: 'Review', detail: '10 dimension subagents scan devplacepy/ and tests/ in parallel' },
{ title: 'Review', detail: '12 dimension subagents scan devplacepy/ and tests/ in parallel' },
{ title: 'Verify', detail: 'adversarially refute each candidate finding against the actual source' },
],
}
@@ -19,6 +19,8 @@ const DIMENSIONS = [
{ key: 'fanout', agent: 'fanout-maintainer' },
{ key: 'docs', agent: 'docs-maintainer' },
{ key: 'test', agent: 'test-maintainer' },
{ key: 'background', agent: 'background-maintainer' },
{ key: 'locust', agent: 'locust-maintainer' },
]
const FINDINGS_SCHEMA = {
@@ -86,7 +88,7 @@ function reportPrompt(dimension) {
function verifyPrompt(dimension, finding) {
return (
`Adversarially verify a candidate "${dimension}" finding. Your goal is to REFUTE it. Open the exact file and read ` +
`You are an independent skeptic, not the agent that raised this finding. A "${dimension}"-dimension maintenance agent flagged the candidate below; your job is solely to REFUTE it from a fresh, unbiased read of the source. Open the exact file and read ` +
`enough surrounding context (the whole function, the caller, the contract) to judge intent. It is REAL only if it ` +
`survives refutation as a genuine violation of the ${dimension} dimension. Rule it out (isReal=false) if it is a ` +
`contract identifier, DATA rather than authored prose, generated or vendored or third-party, or already correct ` +
@@ -116,7 +118,6 @@ const reviewed = await pipeline(
parallel(
((review && review.findings) || []).map((finding) => () =>
agent(verifyPrompt(dimension.key, finding), {
agentType: dimension.agent,
label: `verify:${dimension.key}`,
phase: 'Verify',
schema: VERDICT_SCHEMA,
+285
View File
@@ -0,0 +1,285 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'full-docs-refactor',
description:
'Documentation reality audit: verify every falsifiable claim in README.md, the root CLAUDE.md, every nested CLAUDE.md, and the entire /docs site (prose + docs_api) against the actual source, fix drift in place, and confirm role-gating. Every agent owns a disjoint set of files so there are never write conflicts.',
phases: [
{ title: 'Ground truth', detail: 'extract authoritative facts (routes, CLI, env, deps, test count, package layout, docs registry) from source' },
{ title: 'Root docs', detail: 'audit README.md plus every CLAUDE.md (root and nested per-subsystem) in parallel - one file per agent' },
{ title: 'Docs site', detail: 'audit the docs_api package and every /docs prose section in parallel - disjoint template ownership' },
{ title: 'Gating + validate', detail: 'verify role-gating and run the full validation sweep (import, template compile, em-dash, broken links)' },
],
}
const REPORT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['target', 'changed', 'changes', 'verifiedAccurate'],
properties: {
target: { type: 'string' },
changed: { type: 'boolean' },
changes: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['location', 'wrong', 'fixed'],
properties: {
location: { type: 'string' },
wrong: { type: 'string' },
fixed: { type: 'string' },
source: { type: 'string' },
},
},
},
verifiedAccurate: { type: 'array', items: { type: 'string' } },
gatingIssues: { type: 'array', items: { type: 'string' } },
unverifiable: { type: 'array', items: { type: 'string' } },
},
}
const VALIDATE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['appImports', 'docsApiValid', 'templatesCompile', 'emDashClean', 'brokenLinks', 'gatingClean'],
properties: {
appImports: { type: 'boolean' },
docsApiValid: { type: 'boolean' },
templatesCompile: { type: 'boolean' },
emDashClean: { type: 'boolean' },
brokenLinks: { type: 'array', items: { type: 'string' } },
gatingClean: { type: 'boolean' },
gatingFixes: { type: 'array', items: { type: 'string' } },
notes: { type: 'string' },
},
}
const SHARED_RULES =
'RULES (all mandatory):\n' +
'- The CODE is the source of truth. When docs disagree with code, fix the DOCS, never the code. Do not invent or aspirationally document features. If docs describe something removed/renamed, correct or remove it.\n' +
'- Use Read/Grep/Glob/Bash to CONFIRM every claim before you edit it. Never edit on assumption.\n' +
'- NEVER introduce an em-dash character or its HTML entity; use a hyphen. Replace any em-dash in a passage you rewrite.\n' +
'- Be surgical: change only what is verifiably wrong or verifiably missing from a list/table meant to be complete. Preserve tone, structure, and formatting.\n' +
'- Do not corrupt markdown tables, HTML, or Jinja.\n' +
'DOCS PROSE STRUCTURE (for /docs/*.html templates): the body is <div class="docs-content" data-render> rendered to HTML SERVER-SIDE from markdown; example markup shown as code INSIDE that block stays HTML-entity-escaped (&lt;...&gt;). Real live-demo markup and its <script type="module"> live OUTSIDE that block - update a demo only if the API it shows changed.\n' +
'ROLE GATING: pages flagged admin:true in routers/docs/pages.py 404 for non-admins and are nav-filtered. Every /docs/<slug>.html link must resolve to a real slug (or a real /docs route like download.html/download.md). If a page visible to guests/members links to an admin-only route or admin doc slug, wrap it in {% if is_admin(user) %}...{% endif %}.\n' +
'REPORT: return structured output - target, changed, one entry per fix (location, wrong, fixed, source), the claim categories you verified as accurate, any gating issue, and anything you could not verify.'
function rootPrompt(file, gt) {
const isNested = file !== 'README.md' && file !== 'CLAUDE.md'
const nestedNote = isNested
? ` This is a NESTED CLAUDE.md (Claude Code auto-loads it only when a file under its own directory is read/edited) - its claims must be scoped to that subsystem; do not duplicate content that belongs in the root CLAUDE.md's cross-cutting rules or in a sibling nested file, and do not reintroduce a top-level AGENTS.md or any reference to one (it was deleted - all of its content now lives across the root CLAUDE.md and the nested CLAUDE.md files).`
: ''
return (
`DOCUMENTATION REALITY AUDIT of a single file: ${file}. Verify EVERY falsifiable claim against the actual source and FIX inconsistencies in place. EDIT ONLY ${file}.${nestedNote}\n\n` +
`Verify (where the file claims them): make targets + comments, devplace/devii CLI subcommands + flags, router prefixes/paths, env vars + defaults, config keys + defaults, function/class/helper/table/setting names, file/module paths (must exist), dependency names, version numbers, test counts, and internal links/anchors. For a routing table, env-var table, commands block, or CLI list that is meant to be COMPLETE, add rows that exist in code but are missing. If this file is the root CLAUDE.md, verify its "Subsystem map" table still lists every nested CLAUDE.md that actually exists in the repo and no stale entries for one that was removed.\n\n` +
`AUTHORITATIVE GROUND TRUTH (freshly extracted from this repo - trust it, but re-confirm anything you edit):\n${gt}\n\n` +
SHARED_RULES
)
}
const DOCS_SECTIONS = [
{
key: 'docs_api',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the /docs API reference, which is GENERATED from the `devplacepy/docs_api/` package (groups/ + services_group.py), NOT from templates. EDIT ONLY files under `devplacepy/docs_api/`. For EVERY documented endpoint verify against the real router + schema: method+path exists (grep @router in routers/, account for the main.py mount prefix), documented params/body match the real Form/query params (models.py, route signature), sample_response shape matches the real *Out schema (schemas/), and the stated auth matches the route guard (get_current_user/require_user/require_admin). The admin API groups (containers/gateway/services/admin) must be genuinely admin routes. Keep the group data valid Python (verify `python -c "from devplacepy.docs_api import API_GROUPS; print(len(API_GROUPS))"`). Remove documented endpoints that no longer exist; correct wrong params/paths/responses; note real endpoints the docs omit.',
},
{
key: 'general-a',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX these /docs prose templates (EDIT ONLY these, under devplacepy/templates/docs/): index.html, getting-started.html, getting-started-vibing.html, feed.html, code-farm.html, block-and-mute.html, emoji-shortcodes.html, presence.html. Verify against: routers/{feed,game/,relations,news}.py, rendering.py (emoji shortcodes via build_emoji_shortcodes + `devplace emoji-sync`), services/presence.py + presence_relay.py, config.py presence defaults, main.py GET / home behavior. code-farm documents the /game Code Farm game; block-and-mute documents relations (/block,/block/unblock,/mute,/mute/unmute).',
},
{
key: 'general-b',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX these /docs prose templates (EDIT ONLY these): devii.html, telegram.html, media-gallery.html, notification-settings.html, timezones.html, ai-correction.html, ai-modifier.html, dashboard.html (kind=live). Verify against: services/devii/ (member page), services/telegram/, services/correction.py, services/ai_modifier.py, routers/profile/{notifications,ai_correction,ai_modifier,telegram}.py, database notification prefs (NOTIFICATION_TYPES/NOTIFICATION_CHANNELS + defaults), templating.py local_dt/dt_ago + static/js/LocalTime.js, routers/media.py, routers/docs/views.py + docs_live.py (dashboard facts).',
},
{
key: 'components',
agentType: 'frontend-maintainer',
prompt:
'Audit and FIX the /docs Components pages (EDIT ONLY: components.html and component-*.html under templates/docs/). Source of truth: devplacepy/static/js/components/*.js and devii/*.js. For each page verify the customElements.define tag name, every documented attribute/property (attr/boolAttr/intAttr reads), methods/events, and the singleton access path (app.dialog/app.contextMenu/app.toast/app.lightbox/app.containerTerminals). Confirm the live-demo markup uses attributes that still exist; fix demos referencing removed attributes. component-emoji-picker documents the external emoji-picker-element (confirm it is still loaded in base.html).',
},
{
key: 'styles-tools',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX (EDIT ONLY): styles.html, styles-colors.html, styles-layout.html, styles-responsiveness.html, styles-consistency.html, tools-seo.html, tools-deepsearch.html. Styles pages: every documented CSS --token name/value must match devplacepy/static/css/variables.css; breakpoints/structural rules must match base.css (and feed.css/projects.css for layout examples). Tools pages: verify routes and caps against routers/tools/{seo,deepsearch}.py, services/jobs/{seo,deepsearch}/, and models.py (SeoRunForm.max_pages 1-50; DeepSearch depth 1-4, max_pages 1-30).',
},
{
key: 'devrant',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the /docs devRant compatibility API pages (EDIT ONLY: devrant.html, devrant-auth.html, devrant-rants.html, devrant-comments.html, devrant-users.html, devrant-notifications.html, devrant-clients.html). Source: routers/devrant/ (mounted at /api) and services/devrant/. Also audit the backing devplacepy/docs_devrant.py if the widget data is wrong (it feeds _devrant_endpoints.html) - but only edit it if a claim is factually wrong. Verify each endpoint path (under /api), method, merged query+form+JSON params, the token triple auth, and the dr_ok/dr_error envelope. Reference client dir is examples/devrant/ (fix any stale devranta/ path).',
},
{
key: 'claude',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the /docs Claude Code pages (EDIT ONLY: claude.html, claude-manual.html, claude-agents.html, claude-commands.html, claude-workflows.html). Source of truth for project-specific claims: .claude/agents/*.md, .claude/commands/*.md, .claude/workflows/*.js. Fix any agent/command/workflow list that drifted from what exists, and any count of them. For general Claude Code product facts not verifiable from the repo, be CONSERVATIVE - leave them unless a .claude/ file contradicts.',
},
{
key: 'admin-prose',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Administration prose pages (EDIT ONLY: devii-admin.html, telegram-admin.html, media-moderation.html, soft-delete.html, backups.html, gamification.html, audit-log.html). Sources: services/audit/ + events.md (event count/domains - match events.md self-reported figure), services/backups/ + routers/admin/backups.py (primary-admin-only download via utils.is_primary_admin), database soft-delete (SOFT_DELETE_TABLES) + /admin/trash, utils badges (ACHIEVEMENTS/BADGE_CATALOG/track_action - include the Code Farm badges), routers/media.py + /admin/media, Devii admin caps + config, services/telegram/ admin config. Verify routes, config-field names+defaults, function/class/table names, CLI commands.',
},
{
key: 'devii-internals',
agentType: 'devii-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Devii internals pages (EDIT ONLY: devii-internals.html, devii-architecture.html, devii-tools.html, devii-data.html, devii-security.html, devii-config.html). Source: services/devii/ (session/ package, agentic/, actions/catalog/ package + dispatcher, hub, tasks/, behavior/, virtual_tools/, customization/, client/, rsearch/, email/, container/) and routers/devii.py. Verify: the documented tool/action names exist and their requires_auth/requires_admin/requires_primary_admin/CONFIRM_REQUIRED flags match the catalog; the total action+handler counts; session keying is (owner_kind, owner_id, channel); the persistence tables (devii_conversations/usage_ledger/turns/tasks/lessons/behavior/virtual_tools); the 4013/1013 close codes; financial-data-admin-only; run_js gated by devii_allow_eval; db_* tools primary-admin-only. NOTE session and actions/catalog are PACKAGES now.',
},
{
key: 'bots',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Bots internals pages (EDIT ONLY: bots-internals.html, bots-architecture.html, bots-personas.html, bots-content.html, bots-engagement.html, bots-realism.html, bots-config.html). Source: services/bot/ (config.py for every documented default; llm.py/loop.py/posting.py/helpers.py/social.py/service.py for mechanics). Verify EVERY config default against services/bot/config.py, the service registration name/interval/default_enabled, the [bots] extra (playwright+faker), the referenced function names (generate_post_title, gist_quality_check, _engage_community, persona_article_score, pick_category, strip_label), and the design-narrative numbers (REACT_RATES, MAX_BOTS_PER_ARTICLE, etc.).',
},
{
key: 'services',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Services pages (EDIT ONLY: services-overview.html, services-framework.html, services-data.html, services-gateway.html, services-devii.html, services-news.html, services-bots.html, services-zip.html, services-containers.html, services-dbapi.html, services-pubsub.html). Source: services/ subpackages and the main.py service registrations (the real count of registered services). Verify each service registration name/default_enabled/interval, config fields+defaults, tables, route surface, and source paths (NewsService now lives in services/news/service.py - news is a PACKAGE; runtime dirs default to data/ NOT var/; there is NO in-app container build / ContainerBuildService; /dbapi is READ-ONLY primary-admin-only).',
},
{
key: 'architecture',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Architecture pages (EDIT ONLY: architecture.html, architecture-backend.html, architecture-frontend.html, architecture-styling.html, architecture-conventions.html, architecture-workflow.html, architecture-jobs.html). Source: main.py (request pipeline, middleware order, mounts), routers/ tree, static/js/ (ES6 modules on app, Application.js, dp-* components, shared utils Http/Poller/JobPoller/OptimisticAction/FloatingWindow), templating.py, rendering.py, services/jobs/ (JobService pattern). Fix any file/module path that no longer exists - database/utils/schemas/docs_api are PACKAGES now. Do NOT "fix" the deliberate synchronous-SQLite design to async.',
},
{
key: 'testing-prod',
agentType: 'docs-maintainer',
prompt:
'Audit and FIX the admin-gated /docs Testing + Production pages (EDIT ONLY: testing.html, testing-framework.html, testing-locust.html, testing-make.html, testing-cicd.html, production.html, production-deploy.html, production-nginx.html, production-concurrency.html, static-caching.html). Sources: Makefile, pyproject.toml ([tool.pytest.ini_options]), tests/ layout + conftest.py fixtures, locustfile.py, .gitea/workflows/, Dockerfile, docker-compose*.yml, nginx config, config.py (STATIC_VERSION). Verify every make target + behavior, the live test count (run `python -m pytest tests/ --collect-only -q | tail -1`), the tier layout, fixtures, ports, CI steps, the worker model (make prod = nproc; the Docker image pins 2 - keep that distinction), nginx WS-upgrade locations, and /static/v<version>/ caching.',
},
]
function sectionPrompt(section, gt) {
return (
section.prompt +
`\n\nAUTHORITATIVE GROUND TRUTH (freshly extracted from this repo - trust it, re-confirm what you edit):\n${gt}\n\n` +
SHARED_RULES
)
}
function selected(list) {
const only = args && args.only
if (!only) return list
const keys = Array.isArray(only) ? only : String(only).split(',').map((s) => s.trim()).filter(Boolean)
return list.filter((item) => keys.includes(item.key))
}
const GT_PROMPT =
'Operate READ-ONLY (do not edit any file). Extract the AUTHORITATIVE, current ground-truth facts of this repository so a documentation audit can cross-check against them. Use Bash/Read/Grep. Produce a compact but complete plain-text reference covering:\n' +
'1. Makefile: every target name and what it actually runs (esp. `prod` worker count, `install` steps, `test`).\n' +
'2. pyproject.toml: version, requires-python, [project.scripts], the full dependency list (note pins), optional-dependency extras.\n' +
'3. CLI: every top-level `devplace` subcommand and its sub-subcommands (from devplacepy/cli/*.py).\n' +
'4. Routers: every prefix mounted in devplacepy/main.py (include_router lines), including no-prefix routers.\n' +
'5. Env vars: every var read in devplacepy/config.py with its default.\n' +
'6. Live test count: `python -m pytest tests/ --collect-only -q | tail -1`.\n' +
'7. Package-vs-file: for database, utils, schemas, models, docs_api, seo, config, constants, rendering, templating - state whether each is a devplacepy/<name>.py FILE or a devplacepy/<name>/ PACKAGE.\n' +
'8. Docs registry: total DOCS_PAGES count, section names, count of admin-gated pages, and the list of docs_api API_GROUPS slugs.\n' +
'Return this as your final text - it will be injected verbatim into every downstream audit agent, so make it accurate and self-contained.'
log('Phase 1: extracting ground truth from source')
phase('Ground truth')
const groundTruth =
(await agent(GT_PROMPT, { agentType: 'docs-maintainer', label: 'ground-truth', phase: 'Ground truth' })) ||
'Ground-truth extraction failed; verify every claim directly against source before editing.'
log('Phase 2: auditing README.md and every CLAUDE.md (root + nested) in parallel')
phase('Root docs')
const ROOT_FILES = [
{ key: 'readme', file: 'README.md' },
{ key: 'claude-root', file: 'CLAUDE.md' },
{ key: 'nested-routers', file: 'devplacepy/routers/CLAUDE.md' },
{ key: 'nested-routers-projects', file: 'devplacepy/routers/projects/CLAUDE.md' },
{ key: 'nested-routers-docs', file: 'devplacepy/routers/docs/CLAUDE.md' },
{ key: 'nested-routers-devrant', file: 'devplacepy/routers/devrant/CLAUDE.md' },
{ key: 'nested-services', file: 'devplacepy/services/CLAUDE.md' },
{ key: 'nested-services-audit', file: 'devplacepy/services/audit/CLAUDE.md' },
{ key: 'nested-services-backup', file: 'devplacepy/services/backup/CLAUDE.md' },
{ key: 'nested-services-bot', file: 'devplacepy/services/bot/CLAUDE.md' },
{ key: 'nested-services-containers', file: 'devplacepy/services/containers/CLAUDE.md' },
{ key: 'nested-services-dbapi', file: 'devplacepy/services/dbapi/CLAUDE.md' },
{ key: 'nested-services-devii', file: 'devplacepy/services/devii/CLAUDE.md' },
{ key: 'nested-services-email', file: 'devplacepy/services/email/CLAUDE.md' },
{ key: 'nested-services-game', file: 'devplacepy/services/game/CLAUDE.md' },
{ key: 'nested-services-gitea', file: 'devplacepy/services/gitea/CLAUDE.md' },
{ key: 'nested-services-jobs', file: 'devplacepy/services/jobs/CLAUDE.md' },
{ key: 'nested-services-messaging', file: 'devplacepy/services/messaging/CLAUDE.md' },
{ key: 'nested-services-news', file: 'devplacepy/services/news/CLAUDE.md' },
{ key: 'nested-services-openai-gateway', file: 'devplacepy/services/openai_gateway/CLAUDE.md' },
{ key: 'nested-services-pubsub', file: 'devplacepy/services/pubsub/CLAUDE.md' },
{ key: 'nested-services-telegram', file: 'devplacepy/services/telegram/CLAUDE.md' },
{ key: 'nested-services-xmlrpc', file: 'devplacepy/services/xmlrpc/CLAUDE.md' },
{ key: 'nested-database', file: 'devplacepy/database/CLAUDE.md' },
{ key: 'nested-utils', file: 'devplacepy/utils/CLAUDE.md' },
{ key: 'nested-static-js', file: 'devplacepy/static/js/CLAUDE.md' },
{ key: 'nested-templates', file: 'devplacepy/templates/CLAUDE.md' },
{ key: 'nested-tests', file: 'tests/CLAUDE.md' },
]
const rootReports = await parallel(
selected(ROOT_FILES).map((root) => () =>
agent(rootPrompt(root.file, groundTruth), {
agentType: 'docs-maintainer',
label: `root:${root.key}`,
phase: 'Root docs',
schema: REPORT_SCHEMA,
})
)
)
log('Phase 3: auditing the docs_api package and every /docs prose section in parallel')
phase('Docs site')
const sectionReports = await parallel(
selected(DOCS_SECTIONS).map((section) => () =>
agent(sectionPrompt(section, groundTruth), {
agentType: section.agentType,
label: `docs:${section.key}`,
phase: 'Docs site',
schema: REPORT_SCHEMA,
})
)
)
log('Phase 4: verifying role-gating and running the validation sweep')
phase('Gating + validate')
const rootFileList = ROOT_FILES.map((f) => f.file).join(', ')
const validatePrompt =
'The documentation audit edits are complete. Run the final VERIFICATION over the repo and FIX any residual gating issue you find (edit only routers/docs/pages.py flags or add {% if is_admin(user) %} guards in the specific template that leaks an admin link). Do the following with Bash and report structured results:\n' +
'1. `python -c "from devplacepy.main import app"` imports clean (appImports).\n' +
'2. `python -c "from devplacepy.docs_api import API_GROUPS; print(len(API_GROUPS))"` works (docsApiValid).\n' +
'3. Every template under devplacepy/templates/docs/ compiles via the shared Jinja env (templatesCompile). Report any that fail.\n' +
`4. No em-dash character or entity in any of: ${rootFileList}, or any devplacepy/templates/docs/*.html (emDashClean).\n` +
'5. Broken internal links: every /docs/<slug>.html href in the doc templates must resolve to a real DOCS_PAGES slug OR a real /docs route (download.html/download.md); list any that do not (brokenLinks).\n' +
'6. Role-gating: no page whose content is admin-only is left ungated (admin:true in pages.py), and no public (non-admin) page links to an admin-gated slug outside an {% if is_admin(user) %} block. Fix violations; report gatingClean + gatingFixes.\n' +
'7. Confirm AGENTS.md does not exist at the repo root (`test -f AGENTS.md && echo EXISTS || echo ABSENT` must print ABSENT) and grep the repo for stray `AGENTS.md` references outside third-party/vendor/backup paths (.venv, *.bak, .git); report any as gatingIssues so a human can decide whether to fix them (this workflow does not own arbitrary non-doc files, e.g. .claude/ agent/command/workflow definitions).\n' +
'Confirm each item against actual command output; do not guess.'
const validation = await agent(validatePrompt, {
agentType: 'docs-maintainer',
label: 'gating+validate',
phase: 'Gating + validate',
schema: VALIDATE_SCHEMA,
})
const roots = rootReports.filter(Boolean)
const sections = sectionReports.filter(Boolean)
const totalFixes =
roots.reduce((n, r) => n + ((r && r.changes && r.changes.length) || 0), 0) +
sections.reduce((n, r) => n + ((r && r.changes && r.changes.length) || 0), 0)
log(`Done. ${totalFixes} documentation fix(es) applied across ${roots.length} root file(s) and ${sections.length} /docs section(s).`)
return {
workflow: 'full-docs-refactor',
totalFixes,
rootDocs: roots,
docsSections: sections,
validation,
}
+16 -8
View File
@@ -1,13 +1,14 @@
// retoor <retoor@molodetz.nl>
export const meta = {
name: 'job-service',
description: 'Scaffold an async JobService (the zip/fork pattern): the JobService subclass, enqueue/status/download routes, the JobOut schema, main.py registration, Devii tools, JobPoller frontend, and docs, then verify',
description: 'Scaffold an async JobService (the zip/fork pattern): the JobService subclass, enqueue/status/download routes, the JobOut schema, main.py registration, Devii tools, JobPoller frontend, and docs, then verify and write the integration tests (enqueue, status, download) in the api tier',
phases: [
{ title: 'Understand', detail: 'read ZipService and ForkService as the template' },
{ title: 'Plan', detail: 'a per-touchpoint plan for the new job kind' },
{ title: 'Implement', detail: 'build the service and all consumers in the repo' },
{ title: 'Verify', detail: 'completeness, security, and audit-log review' },
{ title: 'Fix', detail: 'close gaps and write the job tests' },
{ title: 'Fix', detail: 'close gaps from the review' },
{ title: 'Test', detail: 'write the api-tier integration tests for enqueue, status, and download' },
],
}
@@ -18,7 +19,7 @@ const RULES = [
'- Runtime artifacts live in config.DATA_DIR (the var/ dir), OUTSIDE the devplacepy package and NOT under /static. Heavy compression or blocking work runs in a subprocess. SQLite stays synchronous.',
'- Enqueue endpoints own authz (require_user plus any resource guard); status and download are capability URLs scoped only by the unguessable uuid7. Soft-delete the job tracking rows; permanent artifacts are not deleted by cleanup().',
'- Record audit events with record_system in the service. Frontend status polling uses JobPoller, never a bespoke loop.',
'- Validate with "hawk ." and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
'- Validate with the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". NEVER run the test suite. Never perform any git write.',
].join('\n')
const CHECKLIST = [
@@ -31,7 +32,14 @@ const CHECKLIST = [
'6. docs_api.py - endpoint() entries for the enqueue, status, and download routes.',
'7. static/js - wire JobPoller.run(statusUrl, {onDone, onFailed, onTimeout}) on the triggering element.',
'8. CLI (optional) - a prune/clear subcommand if artifacts accumulate.',
'9. README.md + AGENTS.md - document the new job kind.',
'9. README.md + devplacepy/services/jobs/CLAUDE.md - document the new job kind.',
].join('\n')
const TESTS = [
'DevPlace test standard (a hard project requirement - one test file per endpoint, the directory tree mirroring the URL path):',
'A job kind is exercised over HTTP, so its tests live in tests/api/ against the live uvicorn subprocess (app_server/seeded_db, requests/httpx vs BASE_URL), one file per route path (POST /projects/{slug}/zip -> tests/api/projects/zip.py; GET /zips/{uid} -> tests/api/zips/index.py). Cover enqueue (authz + a job uid back), status (the *JobOut shape and lifecycle), and download/result (the capability URL) where applicable.',
'Because the service loop only runs in the lock owner and tests set DEVPLACE_DISABLE_SERVICES=1, assert the enqueue contract and the pending/known status shape rather than waiting on real completion; if you need a finished job, drive process() directly in a unit test under tests/unit/services/jobs/.',
'Required patterns: scoped assertions; try/finally restore of any flipped global setting; the shared fixtures; raw inserts into a soft-delete table set deleted_at/deleted_by. Validate by a clean import only. NEVER run the suite.',
].join('\n')
function jobBrief() {
@@ -125,7 +133,7 @@ const plan = await agent(
)
const build = await agent(
`Implement this new async job kind coherently, editing files directly in the repo, mirroring ZipService/ForkService and following the plan. Keep the *JobOut schema, routes, Devii tools, and docs in agreement. Then run "hawk ." and "python -c \\"from devplacepy.main import app\\"". Do not write tests here. Do not run the suite. Do not commit.\n\nJob request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${CHECKLIST}\n\n${RULES}\n\nReturn the job kind, files changed, and whether validator and import passed.`,
`Implement this new async job kind coherently, editing files directly in the repo, mirroring ZipService/ForkService and following the plan. Keep the *JobOut schema, routes, Devii tools, and docs in agreement. Then run the per-language checks (py_compile + pyflakes for Python, node --check for JS, brace balance for CSS, tag and {% %} balance for templates) and "python -c \\"from devplacepy.main import app\\"". Do not write tests here. Do not run the suite. Do not commit.\n\nJob request: ${ask}\n\nPlan:\n${JSON.stringify(plan, null, 2)}\n\n${CHECKLIST}\n\n${RULES}\n\nReturn the job kind, files changed, and whether the checks and the import passed.`,
{ label: 'implement', phase: 'Implement', schema: BUILD_SCHEMA }
)
@@ -154,14 +162,14 @@ const gaps = audits
let gapFix = 'no actionable gaps'
if (gaps.length) {
gapFix = await agent(
`Close these job-service gaps with minimal root-cause fixes in the repo, then re-run "hawk .". Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
`Close these job-service gaps with minimal root-cause fixes in the repo, then re-run the per-language checks. Do not run the suite. Do not commit.\n\nGaps:\n${JSON.stringify(gaps, null, 2)}\n\n${RULES}`,
{ label: 'fix-gaps', phase: 'Fix' }
)
}
const tests = await agent(
`Operate in FIX mode. Write the integration tests for the new job kind (enqueue, status, download) following the required patterns and the directory-mirrors-path layout. Validate by a clean import only. NEVER run the suite.\n\nJob request: ${ask}\nKind: ${build && build.kind}\nFiles changed:\n${changed.join('\n')}`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Fix' }
`Operate in FIX mode. Write the integration tests for the new job kind (enqueue, status, download) following the required patterns and the directory-mirrors-path layout. The job kind is not complete until each of its routes has a test. Create any missing package directories the test paths need. Validate by a clean import only. NEVER run the suite.\n\n${TESTS}\n\nJob request: ${ask}\nKind: ${build && build.kind}\nFiles changed:\n${changed.join('\n')}\n\nReturn the test files written and the routes they cover.`,
{ agentType: 'test-maintainer', label: 'tests', phase: 'Test' }
)
return { ask, map, plan, build, audit: gaps, gapFix, tests }
+5 -2
View File
@@ -19,6 +19,9 @@ const DIMENSIONS = [
{ key: 'docs', agent: 'docs-maintainer' },
{ key: 'seo', agent: 'seo-maintainer' },
{ key: 'test', agent: 'test-maintainer' },
{ key: 'devii', agent: 'devii-maintainer' },
{ key: 'background', agent: 'background-maintainer' },
{ key: 'locust', agent: 'locust-maintainer' },
]
const DIFF_SCHEMA = {
@@ -102,8 +105,8 @@ const reviewed = await pipeline(
parallel(
((review && review.findings) || []).map((finding) => () =>
agent(
`Adversarially verify a candidate "${dimension.key}" review finding. Try to REFUTE it: open the file, read the changed region and its context, and decide if it is a genuine violation introduced by this diff. Rule it out (isReal=false) if it is a contract identifier, DATA rather than prose, vendored, pre-existing and untouched by this diff, or already correct under a known exemption. When uncertain, default to isReal=false.\n\nFinding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}`,
{ agentType: dimension.agent, label: `verify:${dimension.key}`, phase: 'Verify', schema: VERDICT_SCHEMA }
`You are an independent skeptic, not the agent that raised this finding. A "${dimension.key}"-dimension maintenance agent flagged the candidate below in this diff; your job is solely to REFUTE it from a fresh, unbiased read of the source. Open the file, read the changed region and its context, and decide if it is a genuine violation introduced by this diff. Rule it out (isReal=false) if it is a contract identifier, DATA rather than prose, vendored, pre-existing and untouched by this diff, or already correct under a known exemption. When uncertain, default to isReal=false.\n\nFinding:\n- file: ${finding.file}\n- line: ${finding.line == null ? 'unspecified' : finding.line}\n- severity: ${finding.severity}\n- rule: ${finding.rule}\n- message: ${finding.message}`,
{ label: `verify:${dimension.key}`, phase: 'Verify', schema: VERDICT_SCHEMA }
).then((verdict) => ({ ...finding, dimension: dimension.key, verdict }))
)
)
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+10
View File
@@ -14,7 +14,10 @@ notification-private.pem
notification-private.pkcs8.pem
notification-public.pem
.pytest_cache/
.ruff_cache/
.opencode
.dpc/
.claude/settings.local.json
devii_*.db
devii_*.db-shm
devii_*.db-wal
@@ -32,3 +35,10 @@ var/
.coverage
.coverage.*
htmlcov/
# local environments and scratch
.venv/
tmp/
*.log
*.bak
test.db
-2040
View File
File diff suppressed because one or more lines are too long
+636
View File
@@ -0,0 +1,636 @@
# DevPlace architecture atlas
Author: retoor <retoor@molodetz.nl>
Every diagram below was derived from the source tree, the live SQLite schema and the imported FastAPI app object, not from the documentation. Where the repository documentation and the running code disagree, the disagreement is recorded in the last section.
| Measure | Value |
|---|---|
| python files | 644 |
| routers | 38 |
| http paths | 348 |
| websockets | 12 |
| services | 27 |
| db tables | 102 |
| agent tools | 318 |
| test functions | 3073 |
## 01 Deployment topology
Source: `docker-compose.yml / Dockerfile / nginx/nginx.conf.template`
Two containers on one bridge network, on a single host. nginx is the only published listener and binds to loopback only. The app container bind-mounts the repository root, so the production process reads the same SQLite file and the same `data/` tree as `make dev`.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
client["Browser / API client / Telegram / devRant client"]
subgraph host["Single host"]
subgraph appnet["docker network: appnet"]
nginx["nginx container<br/>127.0.0.1:PORT to :80<br/>8 websocket upgrade locations"]
app["app container<br/>uvicorn devplacepy.main:app<br/>--workers 2 --backlog 8192"]
end
repo["repository root<br/>bind mount .:/app"]
static["devplacepy/static<br/>read only mount"]
data["DEVPLACE_DATA_DIR = data/<br/>23 registered paths"]
sqlite[("data/devplace.db<br/>SQLite, WAL, 256MB mmap")]
lock["data/locks/devplace-services.lock<br/>flock, elects one service owner"]
docker["host Docker daemon<br/>ppy:latest instances"]
end
client --> nginx
nginx -->|"proxy_pass, X-Real-IP"| app
nginx -->|"/static/, /static/uploads/"| static
app --> repo
app --> data
data --> sqlite
app --> lock
app -->|"docker CLI backend"| docker
```
nginx depends_on app with condition service_healthy. App healthcheck: interval 30s, timeout 10s, retries 3, start_period 120s, start_interval 2s.
## 02 Worker boot and service election
Source: `devplacepy/main.py lifespan`
Every uvicorn worker runs the full lifespan. Schema work is serialized behind an exclusive lock so workers pay it one after another; background services are elected, so exactly one worker in the host owns them.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
s1["ensure_data_dirs<br/>creates all 23 DATA_PATHS"]
s2["init_lock<br/>exclusive flock on INIT_LOCK_FILE"]
s3["init_db<br/>idempotent columns and indexes"]
s4["ensure_certificates<br/>VAPID keys in data/keys"]
s5["service_manager.register x27"]
gate{"DEVPLACE_DISABLE_SERVICES set?"}
skip["services registered but never started<br/>test mode"]
bg["background.start<br/>per worker asyncio queue"]
elect{"acquire_service_lock"}
owner["set_lock_owner True<br/>supervise all 27 services"]
decline["declined, another worker owns them"]
vis["start_visit_flusher"]
serve(["serving on port 10500"])
s1 --> s2 --> s3 --> s4 --> s5 --> gate
gate -->|yes| skip --> vis
gate -->|no| bg --> elect
elect -->|"lock acquired"| owner --> vis
elect -->|"lock held elsewhere"| decline --> vis
vis --> serve
```
Shutdown reverses it: flush_visits, service_manager.shutdown_all, background.stop.
## 03 Request pipeline
Source: `devplacepy/main.py, verified against app.user_middleware`
Ten middlewares wrap every request. The order below is the real execution order read off the built middleware stack, outermost first. Two of them are plain ASGI classes; the other eight are `BaseHTTPMiddleware` dispatch functions.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
req(["incoming ASGI scope"])
m0["0 GZipMiddleware<br/>minimum_size 512, level 5"]
m1["1 TunnelDispatchMiddleware<br/>host matches a workspace tunnel?"]
tun["routers/tunnel.py<br/>handle_http / handle_ws"]
m2["2 response_timing<br/>request.state.request_start, X-Response-Time"]
m3["3 visit_statistics"]
m4["4 track_presence<br/>throttled last_seen write"]
m5["5 maintenance_middleware<br/>503 unless static, avatar, auth, admin"]
m6["6 rate_limit_middleware<br/>mutating methods only, per IP bucket"]
m7["7 add_security_headers"]
m8["8 await_pending_corrections<br/>drains sync AI correction futures"]
m9["9 refresh_db_snapshot"]
mounts["mounts: /static/uploads, /static/v{ts}, /static"]
routes["38 included routers"]
handlers["exception handlers<br/>404, 500, RequestValidationError"]
req --> m0 --> m1
m1 -->|"tunnel host"| tun
m1 -->|"normal host"| m2 --> m3 --> m4 --> m5 --> m6 --> m7 --> m8 --> m9
m9 --> mounts
m9 --> routes
routes --> handlers
```
Rate limiting reads rate_limit_per_minute and rate_limit_window_seconds from site_settings and exempts reads and the /openai gateway. Bucket key is X-Real-IP falling back to request.client.host.
## 04 URL surface
Source: `devplacepy/main.py include_router calls`
38 routers, each mounted under one prefix, grouped here by the concern they serve. Directory shape mirrors the URL: a domain with one resource is a flat module, a domain with several sub-resources is a package that aggregates its leaves in `__init__.py`.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
app(["FastAPI app"])
subgraph identity["Identity and profile"]
r1["/auth - auth/"]
r2["/profile - profile/"]
r3["/avatar - avatar.py"]
r4["/follow - follow.py"]
r5["(none) - relations.py block, mute"]
end
subgraph social["Content and engagement"]
r6["/feed - feed.py"]
r7["/posts - posts.py"]
r8["/comments - comments.py"]
r9["/gists - gists.py"]
r10["/news - news.py"]
r11["/votes - votes.py"]
r12["/reactions - reactions.py"]
r13["/bookmarks - bookmarks.py"]
r14["/polls - polls.py"]
r15["/awards - awards.py"]
r16["/leaderboard - leaderboard.py"]
r17["/notifications - notifications.py"]
r18["/messages - messages.py"]
r19["/uploads - uploads.py"]
r20["/media - media.py"]
end
subgraph work["Projects and compute"]
r21["/projects - projects/ incl files, containers"]
r22["/p - proxy.py container ingress"]
r23["/zips - zips.py"]
r24["/forks - forks.py"]
r25["/issues - issues/"]
end
subgraph aiml["AI and tools"]
r26["/openai - openai_gateway.py"]
r27["/devii - devii.py"]
r28["/tools - tools/ seo, deepsearch, isslop"]
end
subgraph play["Play"]
r29["/game - game/"]
r30["/quizzes - quizzes/"]
end
subgraph platform["Platform"]
r31["/admin - admin/ 23 modules"]
r32["/docs - docs/"]
r33["(none) - push.py, seo.py"]
end
subgraph machine["Machine interfaces"]
r34["/api - devrant/"]
r35["/dbapi - dbapi/ read only"]
r36["/xmlrpc - xmlrpc.py"]
r37["/pubsub - pubsub.py"]
end
app --> identity
app --> social
app --> work
app --> aiml
app --> play
app --> platform
app --> machine
```
| Prefix | Paths | Prefix | Paths | Prefix | Paths |
|---|---|---|---|---|---|
| /admin | 92 | /profile | 17 | /gists | 5 |
| /projects | 46 | /issues | 13 | /messages | 5 |
| /tools | 27 | /dbapi | 9 | /notifications | 5 |
| /game | 23 | /auth | 6 | /uploads | 5 |
| /quizzes | 21 | /devii | 5 | /docs | 4 |
| /api | 20 | /posts | 4 | everything else | 36 |
## 05 Route fan-out
Source: `devplacepy/responses.py, schemas/, docs_api/, services/devii/actions/`
One handler serves four consumers. `respond()` feeds the same context dict to a Pydantic model and to a Jinja template, so a key missing from the `*Out` schema is silently absent from JSON while still rendering in HTML. The agent catalog and the docs registry are separate declarations that must be kept in step by hand.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
form["models.py<br/>Pydantic form model via Annotated Form"]
guard["guard: get_current_user<br/>require_user / require_admin"]
handler["router handler"]
helpers["database/ batch helpers<br/>get_users_by_uids, build_pagination"]
ctx["context dict"]
neg{"wants_json(request)"}
json["model.model_validate<br/>JSONResponse"]
html["templates.TemplateResponse<br/>extends base.html"]
action["services/devii/actions catalog<br/>Action, requires_auth mirrors guard"]
docs["docs_api endpoint()<br/>params and sample_response"]
seo["seo.py base_seo_context<br/>JSON-LD, sitemap entry"]
form --> handler
guard --> handler
handler --> helpers --> ctx --> neg
neg -->|"json accepted"| json
neg -->|"otherwise"| html
handler -.->|"same capability"| action
handler -.->|"same contract"| docs
html -.-> seo
```
wants_json is true when the request content-type starts with application/json, or Accept contains application/json and does not contain text/html. Registry sizes: 318 agent actions, 259 documented endpoint entries across 20 API groups, 122 prose docs pages.
## 06 Identity resolution
Source: `devplacepy/utils/auth.py`
A single resolver serves browsers, API clients, the devRant compatibility layer and issued access tokens. The result is memoised on `request.state` for the request and in a process TTL cache keyed by credential.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
start(["get_current_user"])
cached{"request.state._auth_user set?"}
sess{"session cookie<br/>64 hex chars"}
xapi{"X-API-KEY header"}
bearer{"Authorization: Bearer"}
basic{"Authorization: Basic"}
triple["try in order:<br/>users.api_key,<br/>devrant token 40 hex,<br/>access token 64 chars"]
user(["user dict"])
guest(["None, guest"])
gu["require_user: 303 to /"]
ga["require_admin: redirect to /feed"]
start --> cached
cached -->|yes| user
cached -->|no| sess
sess -->|match| user
sess -->|no| xapi
xapi -->|present| triple
triple -->|match| user
triple -->|no match| bearer
xapi -->|absent| bearer
bearer -->|present| triple
bearer -->|absent| basic
basic -->|"username-or-email:password, pbkdf2_sha256"| user
basic -->|no| guest
guest --> gu
user --> ga
```
Roles are stored capitalized as Admin or Member and tested through the is_admin global. Admin seniority is enforced per user mutation in routers/admin/users.py.
## 07 Background service fleet
Source: `devplacepy/services/manager.py, base.py, jobs/base.py`
27 singletons registered at boot, supervised only by the worker that won the service lock. Sixteen are long-lived loops on `BaseService`; eleven are queue consumers on `JobService`, which adds retention, concurrency and timeout settings on top and drains rows from the shared `jobs` table.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
mgr["ServiceManager<br/>supervise, shutdown_all"]
settings[("site_settings<br/>enabled flag and interval per service")]
jobs[("jobs table")]
subgraph base["BaseService loops - 16"]
b1["NewsService"]
b2["BotsService"]
b3["GatewayService"]
b4["DeviiService"]
b5["PubSubService"]
b6["NotificationRelayService"]
b7["LiveViewRelayService"]
b8["PresenceRelayService"]
b9["IssueTrackerService"]
b10["ContainerService"]
b11["WorkspaceService"]
b12["XmlrpcService"]
b13["AuditService"]
b14["PushService"]
b15["TelegramService"]
b16["TelegramOutboxService"]
end
subgraph job["JobService consumers - 11"]
j1["ZipService"]
j2["ForkService"]
j3["SeoService"]
j4["SeoMetaService"]
j5["AwardService"]
j6["BackupService"]
j7["DbApiJobService"]
j8["DeepsearchService"]
j9["IsslopService"]
j10["IssueCreateService"]
j11["PlanningReportService"]
end
mgr --> base
mgr --> job
settings --> mgr
job --> jobs
```
Separate from the fleet, services/background.py offers a fire and forget queue per worker for audit writes, XP awards and notifications; when no consumer runs, as in tests, the callable executes inline so ordering stays deterministic.
## 08 Data layer
Source: `devplacepy/database/, devplacepy/config.py`
One SQLite file reached through `dataset`, called synchronously from async handlers by design. 102 tables exist in the live schema; 52 of them carry the soft delete pair and are restorable as one event from the admin trash.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
db[("data/devplace.db")]
subgraph g1["Identity and access - 14"]
t1["users, sessions, password_resets<br/>access_tokens, devrant_tokens<br/>user_relations, follows<br/>notification_preferences, user_customizations<br/>push_registration, email_accounts<br/>telegram_links, telegram_pairings, telegram_outbox"]
end
subgraph g2["Content and engagement - 16"]
t2["posts, comments, gists<br/>projects, project_files, project_forks<br/>attachments, news, news_images, news_sync<br/>polls, poll_options, poll_votes<br/>votes, reactions, bookmarks"]
end
subgraph g3["Reputation - 5"]
t3["awards, badges, award_usage<br/>user_activity, user_activity_seen"]
end
subgraph g4["Code Farm - 9"]
t4["game_farms, game_plots, game_quests<br/>game_cosmetics, game_market_ticks<br/>game_steals, game_treasury<br/>game_eras, game_era_results"]
end
subgraph g5["Quizzes - 5"]
t5["quizzes, quiz_questions, quiz_options<br/>quiz_attempts, quiz_answers"]
end
subgraph g6["Live delivery - 3"]
t6["messages, notifications, ws_tickets"]
end
subgraph g7["Devii - 8"]
t7["devii_conversations, devii_turns<br/>devii_tasks, devii_task_runs<br/>devii_lessons, devii_virtual_tools<br/>devii_behavior, devii_usage_ledger"]
end
subgraph g8["AI gateway - 6"]
t8["gateway_providers, gateway_models<br/>gateway_usage_ledger<br/>gateway_quota_rules, gateway_quota_resets<br/>gateway_concurrency_samples"]
end
subgraph g9["Jobs, tools and usage - 20"]
t9["jobs, backups, backup_schedules<br/>deepsearch_sessions, deepsearch_messages, deepsearch_url_cache<br/>isslop_analyses, isslop_reports, isslop_events<br/>isslop_dom_results, isslop_file_results, isslop_image_results<br/>seo_metadata, seo_usage<br/>issue_tickets, issue_comment_authors, issue_usage<br/>correction_usage, modifier_usage, news_usage"]
end
subgraph g10["Containers - 9"]
t10["instances, instance_events, instance_metrics<br/>tunnels, workspace_flags, workspace_quota_rules<br/>builds, dockerfiles, dockerfile_versions"]
end
subgraph g11["Platform state - 7"]
t11["site_settings, cache_state, service_state<br/>audit_log, audit_log_links<br/>visit_stats_hourly, visit_unique_slots"]
end
db --> g1
db --> g2
db --> g3
db --> g4
db --> g5
db --> g6
db --> g7
db --> g8
db --> g9
db --> g10
db --> g11
```
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
root["DEVPLACE_DATA_DIR<br/>default repo/data"]
blobs["uploads, attachments, project_files<br/>sharded xx/yy on the uuid7 random tail"]
jobsdir["zips, zip_staging, fork_staging<br/>backups, backup_staging"]
reports["seo_reports, planning_reports<br/>dbapi, deepsearch, deepsearch_chroma"]
isslop["isslop, isslop_workspaces<br/>isslop_runs, isslop_media"]
ws["container_workspaces, workspace_state"]
keys["keys VAPID, bot, locks"]
dbs["devplace.db, devii_tasks.db, devii_lessons.db"]
root --> blobs
root --> jobsdir
root --> reports
root --> isslop
root --> ws
root --> keys
root --> dbs
```
config.DATA_PATHS registers 23 directories and ensure_data_dirs creates the whole tree before any write. Uploads live under data/uploads but are served at the unchanged /static/uploads URL.
## 09 Content rendering
Source: `devplacepy/rendering.py, static/js/ContentRenderer.js`
Two pipelines with a deliberate split: anything that exists at request time is rendered on the server for SEO, and the client pipeline is reserved for content that does not exist yet. Each has its own XSS control at a different point.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"13px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
subgraph server["Server, rendering.py, lru_cache"]
a1["raw text"]
a2["normalize dashes to hyphen"]
a3["emoji shortcodes, 4869 names"]
a4["mistune GFM, escape=True"]
a5["media pass: bare URLs to embeds, mentions to links"]
a6["mask emails on rendered text nodes"]
a7["render_content / render_title in template"]
a8["ContentEnhancer adds highlighting and copy buttons"]
a1 --> a2 --> a3 --> a4 --> a5 --> a6 --> a7 --> a8
end
subgraph client["Client, ContentRenderer.js"]
b1["live text: comments, DM bubbles, Devii, DeepSearch"]
b2["marked"]
b3["DOMPurify.sanitize, fail closed"]
b4["highlight.js"]
b5["media and autolink pass"]
b1 --> b2 --> b3 --> b4 --> b5
end
```
The server escapes at the markdown step, the client sanitizes after parsing. Server rendered content must not carry data-render, or both pipelines run over it.
## 10 AI plane
Source: `devplacepy/routers/openai_gateway.py, services/openai_gateway/, services/devii/`
Every model call in the product, internal or external, leaves through one gateway, which is where routing, quota and cost attribution live. Internal consumers call it over loopback HTTP rather than importing a client, so their spend lands in the same ledger.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
ext["external client<br/>/openai/v1/*"]
subgraph consumers["Internal consumers via INTERNAL_GATEWAY_URL"]
c1["services/devii"]
c2["services/correction<br/>AI correction and modifier"]
c3["services/news"]
c4["services/bot"]
c5["services/deepsearch + jobs/deepsearch"]
c6["services/dbapi nl2sql"]
c7["services/gitea enhance + planning"]
c8["jobs/isslop"]
end
gw["gateway.py<br/>routing.py, quota.py, usage.py<br/>reliability.py, vision.py"]
ledger[("gateway_usage_ledger<br/>gateway_quota_rules")]
stealth["stealth_async_client<br/>curl_cffi Chrome 146 fingerprint"]
up["upstream provider"]
subgraph devii["Devii assistant"]
d1["registry.py CATALOG<br/>318 tools"]
d2["role gating<br/>guest 108, member 246<br/>admin 312, primary 318"]
d3["45 tools behind CONFIRM_REQUIRED"]
d4["session, tasks, lessons<br/>virtual tools, behavior"]
d5["surfaces: /devii ws, Telegram, CLI"]
end
ext --> gw
consumers --> gw
gw --> ledger
gw --> stealth --> up
d1 --> d2 --> d3
devii --> c1
d4 --> d1
d5 --> d4
```
Cleartext loopback calls are forced to HTTP/1.1 in curl_transport.http_version_for, because the Chrome impersonation profile would otherwise negotiate HTTP/2 against an HTTP/1.1 only uvicorn.
## 11 Containers, workspaces and ingress
Source: `devplacepy/services/containers/, routers/projects/containers/, routers/proxy.py, routers/tunnel.py`
One shared image serves every instance. Reconciliation is a loop that compares desired state in the database against what the Docker daemon actually reports, and there are two independent ways in from the outside: a path prefix and a hostname.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
ui["/projects/{slug}/containers<br/>instances, schedules, workspace"]
api["services/containers/api.py"]
store[("instances, instance_events<br/>instance_metrics, tunnels<br/>workspace_flags, workspace_quota_rules")]
svc["ContainerService<br/>reconcile desired vs docker ps"]
wsvc["WorkspaceService<br/>provision, certs, quota, tunnels"]
backend["backend/docker_cli.py<br/>backend/fake.py for tests"]
image["single shared image ppy:latest"]
inst["running instance<br/>rootless workflow, aptroot"]
wsdir["data/container_workspaces/{uid}"]
proxy["/p/{slug}<br/>routers/proxy.py, relays headers verbatim"]
tunnel["host based tunnel<br/>TunnelDispatchMiddleware to routers/tunnel.py"]
code["workspace editor over websocket<br/>/projects/{slug}/containers/instances/{uid}/code"]
exec["terminal over websocket<br/>.../exec/ws"]
ui --> api --> store
svc --> store
wsvc --> store
api --> backend
svc --> backend
wsvc --> wsdir
backend --> image --> inst
proxy --> inst
tunnel --> inst
code --> inst
exec --> inst
```
Access uses stricter predicates than the rest of the product: owns_instance, can_view_project_containers, can_view_instance, can_manage_instance. The primary administrator sees and manages every container; any other admin can only view others on public projects and manage instances they own.
## 12 Real time plane
Source: `routers/*.py websocket handlers, services relays, nginx.conf.template`
Twelve WebSocket endpoints, each of which needs its own nginx upgrade location because the catch-all location strips upgrade headers. Fan-out to connected sockets goes through in-process hubs driven by relay services on the lock-owning worker.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
subgraph sockets["WebSocket endpoints"]
s1["/devii/ws"]
s2["/messages/ws"]
s3["/pubsub/ws"]
s4["/tools/seo/{uid}/ws"]
s5["/tools/deepsearch/{uid}/ws"]
s6["/tools/deepsearch/{uid}/chat"]
s7["/dbapi/query/{uid}/ws"]
s8[".../containers/instances/{uid}/exec/ws"]
s9[".../containers/instances/{uid}/code"]
s10[".../code/{path}"]
s11["/p/{slug}"]
s12["/p/{slug}/{path}"]
end
subgraph relays["Relay services"]
r1["NotificationRelayService"]
r2["LiveViewRelayService"]
r3["PresenceRelayService<br/>track limit 500, online limit 30"]
r4["PubSubService"]
end
hubs["messaging/hub.py, pubsub/hub.py<br/>devii/hub.py"]
tick[("ws_tickets<br/>expiring auth tickets")]
s2 --> hubs
s3 --> hubs
s1 --> hubs
relays --> hubs
tick --> s2
```
Presence is authoritative from the relay: PRESENCE_TRACK_LIMIT sets the tracked online set, PRESENCE_ONLINE_LIMIT only caps how many the feed panel displays, and PRESENCE_ONLINE_MARGIN_SECONDS provides hysteresis at the boundary.
## 13 Frontend
Source: `devplacepy/static/, devplacepy/templates/`
No framework and no package manager. 131 ES6 modules, one class per file, hung off a single global `app`; 33 of them are custom elements. 217 Jinja templates, of which 107 are the docs site.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart TB
base["templates/base.html<br/>extra_head for CSS, extra_js for JS"]
partials["shared partials<br/>_avatar_link, _user_link, _comment_section<br/>_post_card, _pagination, _sidebar_search"]
appjs["static/js/Application.js<br/>instantiated once as app"]
subgraph modules["131 ES6 modules"]
u1["utilities<br/>Http, Poller, JobPoller<br/>OptimisticAction, FloatingWindow, ScrollMemory"]
u2["33 custom elements<br/>components/ with dp- prefix"]
u3["feature modules<br/>chat/, devii/, autoload/"]
end
css["49 stylesheets<br/>variables.css tokens, per page files"]
vers["static_url in Jinja, assetUrl in JS<br/>/static/v{boot ts}/, immutable for a year"]
cust["per user CSS and JS injection<br/>custom_css_tag, custom_js_tag"]
base --> partials
base --> appjs --> modules
base --> css
base --> cust
vers --> css
vers --> modules
```
Per user customizations are configured only through Devii, stored in user_customizations, scoped globally or per matched route template, and run solely in that owner's own browser sessions.
## 14 Test and delivery topology
Source: `tests/, pyproject.toml, .gitea/workflows/test.yaml`
Three tiers separated by what they exercise, decided by fixtures, run serially in a single process against one uvicorn subprocess on port 10501 with a temporary database.
```mermaid
%%{init:{"theme":"base","themeVariables":{"background":"#F4F8F7","primaryColor":"#E4EEED","primaryTextColor":"#0F1A1B","primaryBorderColor":"#0A6D6A","secondaryColor":"#EDE6D9","tertiaryColor":"#F4F8F7","lineColor":"#6B7C7C","textColor":"#0F1A1B","fontFamily":"ui-monospace,SFMono-Regular,Menlo,monospace","fontSize":"12px","clusterBkg":"#FAFCFC","clusterBorder":"#C6D4D3"}}}%%
flowchart LR
unit["tests/unit - 140 modules<br/>mirrors source module path<br/>local_db or no fixture"]
api["tests/api - 221 modules<br/>mirrors URL path<br/>app_server, seeded_db"]
e2e["tests/e2e - 131 modules<br/>mirrors URL path<br/>page, alice, bob"]
srv["uvicorn subprocess :10501<br/>temp SQLite, own DATA_DIR<br/>DEVPLACE_DISABLE_SERVICES=1"]
pw["Playwright Chromium<br/>session scoped context"]
ci["Gitea Actions on push and PR to master<br/>full suite under coverage"]
prod["promotion to production<br/>docker compose"]
unit --> ci
api --> srv --> ci
e2e --> srv
e2e --> pw --> ci
ci --> prod
```
pytest-xdist is not a dependency and -n is rejected centrally, so no tier can be parallelised by accident.
## 15 Findings
Source: `measured against the repository documentation`
The structure holds up: the router tree mirrors the URL tree, the test tree mirrors both, every runtime path is registered in one place, and every model call has a single exit. The items below are the places where the running code and the written record have drifted apart, or where a structure exists that nothing currently uses. None of them is a functional defect.
### Middleware order in the documentation is stale
The root CLAUDE.md states that response_timing is the outermost middleware. Measured from app.user_middleware it is third, behind GZipMiddleware and TunnelDispatchMiddleware, both added after it. The consequence is minor but real: X-Response-Time excludes compression time and excludes tunnel-host dispatch entirely.
### Two catalogue counts have drifted
CLAUDE.md cites around 2882 tests and an events.md catalogue of 288 keys. Measured now: 3073 functions named test_ across 492 modules, and 312 distinct dotted event keys in events.md. Both are undercounts in the docs, not missing implementation.
### instance_schedules is declared soft-deletable but has no table
It appears in database.SOFT_DELETE_TABLES and in the routers under /projects/{slug}/containers/schedules, but no such table exists in the live schema. dataset creates it lazily on first insert, so this is correct only for as long as every read path tolerates the table being absent.
### Three legacy container tables still exist
builds, dockerfiles and dockerfile_versions are present in the live database. The project replaced per-project images with the single shared ppy:latest image and ships devplace containers prune-builds as the one-time cleanup for exactly these rows. The tables are still carried.
### Five duplicate OpenAPI operation IDs
Generating the schema warns on editor_proxy twice in routers/projects/containers/workspace.py, passthrough in routers/openai_gateway.py, and proxy_http twice in routers/proxy.py. These are catch-all routes registered for several methods, so a generated client would collide on those names.
### The one deliberate asymmetry is documented and intentional
routers/__init__.py holds nothing but the attribution line; unlike every nested router package, the top level does not aggregate. main.py performs all 38 include_router calls directly, which keeps prefix ownership in a single readable block.
---
Sources: devplacepy/main.py, the imported FastAPI app object, data/devplace.db sqlite_master, and the working tree at HEAD 192df12b with uncommitted local modifications present. Counts exclude __pycache__ and the virtual environment.
+292 -180
View File
File diff suppressed because one or more lines are too long
+11 -6
View File
@@ -4,6 +4,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates \
libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Optional: the docker CLI so the (admin-only) container manager can drive the host
@@ -18,21 +20,24 @@ RUN if [ "$INSTALL_DOCKER_CLI" = "true" ]; then \
rm -rf /var/lib/apt/lists/* ; \
fi
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
COPY pyproject.toml .
RUN mkdir -p devplacepy && touch devplacepy/__init__.py \
&& pip install --no-cache-dir ".[bots]" \
&& python -m playwright install --with-deps chromium \
&& chmod -R a+rx /ms-playwright
COPY devplacepy/ devplacepy/
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN pip install --no-cache-dir ".[bots]" \
&& python -m playwright install --with-deps chromium \
&& chmod -R a+rx /ms-playwright
RUN pip install --no-cache-dir --no-deps --force-reinstall .
EXPOSE 10500
ENV DEVPLACE_WEB_WORKERS=2
ENV DEVPLACE_TEMPLATE_AUTO_RELOAD=0
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s --start-interval=2s \
CMD curl -f http://localhost:10500/ || exit 1
CMD ["sh", "-c", "DEVPLACE_STATIC_VERSION=${DEVPLACE_STATIC_VERSION:-$(date +%s)} exec uvicorn devplacepy.main:app --host 0.0.0.0 --port 10500 --workers 2 --backlog 8192 --proxy-headers --forwarded-allow-ips '*'"]
+41 -7
View File
@@ -12,7 +12,7 @@ DEVPLACE_RATE_LIMIT ?= 1000000
PYTHONDONTWRITEBYTECODE := 1
export PYTHONDONTWRITEBYTECODE
.PHONY: install dev clean tree tree-loc zip test test-headed coverage coverage-headed coverage-html locust locust-headless
.PHONY: install dev clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless
install:
pip install -e .
@@ -43,19 +43,31 @@ zip:
@printf 'Wrote %s (%s files)\n' "$(notdir $(CURDIR)).zip" "$$(git ls-files | wc -l)"
test:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/
test-headed:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -x
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/
test-unit:
python -m pytest tests/unit -x
python -m pytest tests/unit
test-api:
python -m pytest tests/api -x
python -m pytest tests/api
test-e2e:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e -x
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/e2e
test-fast:
python -m pytest tests/unit tests/api
test-failed:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --last-failed --last-failed-no-failures none
test-first-failure:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -x
test-slowest:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ --durations=40
coverage:
rm -f .coverage .coverage.*
@@ -109,8 +121,12 @@ clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf devplacepy.egg-info
rm -rf .pytest_cache
rm -rf .venv
test-cache-clean:
rm -rf .pytest_cache
# Container Manager works out of the box: the overlay installs the docker CLI in
# the image and mounts the host socket. DOCKER_GID is read straight from the
# socket so the UID-1000 app can use it; the data dir is the project's own data/
@@ -119,10 +135,11 @@ clean:
COMPOSE := docker compose -f docker-compose.yml -f docker-compose.containers.yml
DEVPLACE_DATA_DIR ?= $(CURDIR)/data
DOCKER_GID ?= $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null)
DEVPLACE_CONTAINER_NETWORK ?= bridge
export DEVPLACE_DATA_DIR
export DOCKER_GID
.PHONY: docker-build docker-up docker-down docker-logs docker-clean docker-prep ppy
.PHONY: docker-build docker-up docker-attach docker-reload docker-down docker-logs docker-clean docker-prep ppy
# Build the single shared container image every instance runs. Build once;
# rebuild only when ppy.Dockerfile, the sudo shim, or pagent change.
@@ -137,6 +154,23 @@ docker-build: docker-prep
docker-up: docker-prep
$(COMPOSE) up -d
$(MAKE) docker-attach
# Workspace tunnels reach a container port that was never published on the host,
# so the app must sit on the same docker network as the instances it runs. The
# default bridge rejects the network-scoped aliases compose always sends, so
# this cannot live in docker-compose.containers.yml and is wired here instead.
docker-attach:
@app=$$($(COMPOSE) ps -q app); \
test -n "$$app" || { echo "app container is not running"; exit 1; }; \
docker network connect $(DEVPLACE_CONTAINER_NETWORK) $$app 2>/dev/null \
&& echo "attached app to the $(DEVPLACE_CONTAINER_NETWORK) network" \
|| echo "app is already on the $(DEVPLACE_CONTAINER_NETWORK) network"
docker-reload:
$(COMPOSE) restart app
$(COMPOSE) up -d --wait
$(MAKE) docker-attach
docker-down:
$(COMPOSE) down
+430 -63
View File
@@ -15,16 +15,24 @@ make test-headed # same tests in visible browser
Open `http://localhost:10500`.
A measured structural overview of the whole application, fifteen Mermaid diagrams covering deployment, request pipeline, URL surface, data layer, AI plane, containers and the real-time plane, is in [ARCHITECTURE.md](ARCHITECTURE.md).
PDF export (DeepSearch reports, via weasyprint) needs the Pango text stack installed at system level. The production image installs it; on a development host install it once:
```bash
sudo apt-get install -y libglib2.0-0 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 fonts-dejavu-core
```
## Stack
| Layer | Technology |
|-------|-----------|
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
| Backend | Python 3.12+, FastAPI, Uvicorn (multi-worker in production) |
| Templates | Jinja2 (server-side rendered) |
| Frontend | Pure ES6 JavaScript, one class per file |
| Frontend | Pure ES6 JavaScript, one class per file. Per-tab scroll restoration (`ScrollMemory`): returning to a listing via browser back, reload, or a back/breadcrumb link reliably lands at the previous scroll position on every browser and device; fresh navigations always start at the top |
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
| Auth | Session cookie, API key (`X-API-KEY`/Bearer), or HTTP Basic; PBKDF2-SHA256 via passlib |
| Avatars | Multiavatar (local SVG generation, no external API, <5ms) |
| Avatars | Multiavatar (local SVG generation, no external API, <5ms). Seeded from the username by default; a per-user `avatar_seed` lets the owner or an admin regenerate a fresh random avatar from the profile page (`POST /profile/{username}/regenerate-avatar`). Regeneration is irreversible - the previous avatar cannot be recovered. |
| Outbound HTTP | Stealth client (`devplacepy/stealth.py`): real Chrome fingerprint (TLS JA3/JA4 + HTTP/2 + headers) via `curl_cffi` behind an `httpx` transport adapter, pure-`httpx[http2]` fallback; the single client for every server-side outbound request |
| Coverage | `coverage.py` (`.coveragerc`, subprocess-aware) |
| Load testing | Locust (locustfile.py) |
@@ -35,12 +43,12 @@ Open `http://localhost:10500`.
devplacepy/
main.py # FastAPI app, router registration
config.py # Settings from env vars + .env
database.py # dataset connection, index creation
database/ # dataset connection, index creation (package)
templating.py # Shared Jinja2 environment + globals
avatar.py # Multiavatar generation, URL builder
utils.py # Password hashing, session mgmt, time_ago, notification hook
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
models.py # Pydantic schemas
push.py # Web push crypto, VAPID keys, encrypt/send/register
push/ # Push delivery: provider protocol, Web Push, APNs, registrations
routers/ # One file per domain (auth, feed, posts, push, ...)
templates/ # Jinja2 HTML templates
static/css/ # Page-specific CSS files
@@ -54,33 +62,43 @@ devplacepy/
|--------|---------|
| `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section interleaves authors so no two consecutive posts share an author. |
| `/auth` | Signup, login, logout, forgot/reset password |
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title and content) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. |
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. |
| `/news` | Developer news listing, detail page with comments |
| `/posts` | Post detail, creation |
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title and description), public read |
| `/comments` | Comment creation, deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title and description), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files) |
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
| `/projects/{slug}` | Dedicated project page: one encompassing card with a cover banner and project logo (owner-uploaded through the standard attachment uploader), the title overlaid on the banner, status/type/platform chips, owner-set Website and Repository links, section tabs (Overview, Devlog, Screenshots, Comments, Files), an About section, the Devlog timeline of every post linked to the project (owners post updates straight from the page via the shared composer preset to the `devlog` topic), a Screenshots gallery built from image attachments (owners add more from the More menu), and a sidebar with links, stats and the author card |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence and gap analysis, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}` |
| `/projects/{slug}/containers` | Admin per-project container manager: Dockerfile CRUD with immutable versions, async image builds, and container instance creation. Reachable from the project page via the admin-only **Containers** button |
| `/admin/containers` | Admin **Containers** manager: list, create, edit, and control every container instance across all projects. The list (`/admin/containers`) has inline start/stop/restart/terminal/edit/delete on each row and a create form (pick a project, optionally a run-as user, a boot language with a source editor, restart policy, start-on-boot, plus env/ports/limits/ingress). Each instance has a detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, workspace sync, and a status history, and an edit page (`/admin/containers/{uid}/edit`) |
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence scoring, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}`. `/tools/isslop` is the **AI Usage Analyzer**: classify a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Queue with `POST /tools/isslop/run`, poll `GET /tools/isslop/{uid}` or the event trail at `GET /tools/isslop/{uid}/events`, read the report at `GET /tools/isslop/{uid}/report` (`.md` to download) and embed the SVG authenticity badge from `GET /tools/isslop/{uid}/badge.svg` |
| `/projects/{slug}/containers` | Admin per-project container manager: create and control container instances, all running the shared prebuilt `ppy` image (there is no in-app image building). Reachable from the project page via the admin-only **Containers** button |
| `/admin/containers` | Admin **Containers** manager: list, create, edit, and control container instances across projects, under strict per-user isolation: the primary administrator sees and manages every instance; every other administrator sees instances on public projects plus their own (instances attached to another user's private project are excluded entirely) and manages only the instances they own (created by them or attached to their own project) - all other rows are view-only. The list (`/admin/containers`) has inline start/stop/restart/terminal/edit/delete on each row and a create form (pick a project, optionally a run-as user, a boot language with a source editor, restart policy, start-on-boot, plus env/ports/limits/ingress). Each instance has a detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, workspace sync, and a status history, and an edit page (`/admin/containers/{uid}/edit`) |
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
| `/profile` | Profile view, editing, and a public **Media** tab (`?tab=media`) showing every attachment a user uploaded, newest first |
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
| `/uploads` | Attachment management (full lifecycle for the signed-in user, same files that appear on posts and other content): `POST /uploads/upload` (multipart) and `POST /uploads/upload-url` (from URL) create; `GET /uploads` lists your own attachments (paginated, newest first, optional `linked` filter); `GET /uploads/{uid}` returns one; `PATCH /uploads/{uid}` renames its display filename (the file extension is always preserved); `DELETE /uploads/delete/{uid}` removes one. Reading and modifying another user's attachment is owner-or-admin; files are served at `/static/uploads/` |
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
| `/admin/devii-tasks` | Admin **Devii tasks**: every scheduled task across all owners with its schedule, run count, expiry and failure streak, plus per-task disable and delete |
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). The `POST /messages/send` form remains as a no-JavaScript fallback |
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image, video and audio embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants (the `message` WS frame carries an additive `ai_processed` flag when a pending correction/modification was applied). An opened conversation loads its 500 most recent messages; older history is retained in the database. `GET /messages/conversations` returns the conversation list as JSON for a live client refresh; the `POST /messages/send` form remains as a no-JavaScript fallback and now accepts an attachment-only, empty-content message. `POST /messages/ws-ticket` exchanges a session/API-key auth to a short-lived (30s), single-use ticket a browser WebSocket can carry as `?ticket=...` on the handshake, since a native `WebSocket` cannot set an `Authorization`/`X-API-KEY` header - this is what makes off-session embedding of the chat possible. CLI: `devplace messaging prune-tickets` removes expired tickets |
| `/votes` | Upvote/downvote on posts, comments, projects |
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
| `/polls` | Vote on post-attached polls |
| `/follow` | Follow/unfollow users |
| `/reports` | Content reporting: `POST /reports/{target_type}/{target_uid}` files a report against any user-generated surface, `GET /reports/mine` lists the reports you filed and their outcome, `GET /reports/reasons` serves the reason registry so every client renders the same dialog |
| `/admin/moderation` | Admin **Moderation** queue: reported content oldest-open-first with the response-window badge, one report per detail page with the offender's history, triage (`/status`) and decisions (`/decide`) |
| `/workspaces/index` | Public index of every workspace published to the ingress proxy, with owner, project, maturity label and direct link |
| `/projects/{slug}/workspace` | A member's dev workspace for a project: open/start/stop/delete, quota and idle status, public tunnels, and the **Editor** card holding their editor preferences (`GET`/`POST /projects/{slug}/workspace/editor`) |
| `/block` | Block/unblock a user: hides all of their posts, comments and messages from you everywhere except their own profile, and stops them notifying you. Also reachable directly from every content action bar |
| `/mute` | Mute/unmute a user: stops them creating notifications for you while their content stays visible |
| `/leaderboard` | Contributor ranking by total stars earned |
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing |
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
| `/admin/bots` | Admin **Bot Monitor**: a live grid of the latest low-quality screenshot per running bot persona, each labelled with the bot username, persona, and current action, auto-refreshing |
| `/admin` | Admin panel (user management, news curation, settings) |
@@ -96,22 +114,117 @@ Member progression is driven by activity and peer recognition.
- **Stars** are the net vote score (`upvotes - downvotes`) on a post, project, or gist. A member's total stars is the sum across all their content and is the basis for ranking.
- **XP and levels.** Members earn XP for contributing: posting (10), commenting (2), publishing a project (15) or gist (5), receiving an upvote (5), and gaining a follower (5). Each level requires 100 XP (`level = 1 + xp // 100`). The profile shows the current level and progress to the next.
- **Badges** are awarded once per milestone: first post/comment/project/gist, 10 posts (Prolific), 25 stars (Rising Star), 100 stars (Star Author), 10 followers (Popular), a 7-day activity streak (On Fire), and reaching levels 5 and 10. Badges render with an icon and description on the profile.
- **Badges** are awarded once and never revoked, across several themed groups (First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels). They cover three kinds of achievement: **content and reputation milestones** (10/50/100 posts, 25/100/500 stars, 10/50/100 followers, comment and project and gist counts, following 10 people, 7/30/100-day activity streaks, reaching levels 5/10/25/50/100); **first-time feature use** (your first comment, project, gist, fork, archive download, SEO audit, DeepSearch, AI usage analysis, container, direct message, bookmark, reaction, star given, follow, upload, project file, issue, poll vote, profile customization, and first conversation with Devii); and **usage tiers** for several of those features (for example reading 1/5/15 documentation pages, or giving 50/250 stars). Each profile has a collapsible **Achievements** showcase that lists every badge grouped by theme, with earned ones highlighted and locked ones shown with their unlock condition, so there is always a next prize to chase.
- **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
- **Contribution heatmap and streaks.** Each profile shows a 12-month activity heatmap and the current/longest daily streak, derived from post/comment/gist/project timestamps (no extra storage).
- **Social graph listings.** Each profile has Followers and Following tabs that paginate the follow graph (25 per page) and show a follow/unfollow control for each person. The same data is available as JSON at `GET /profile/{username}/followers` and `GET /profile/{username}/following`.
- **Media gallery.** Each profile has a public Media tab: a responsive, paginated grid of every attachment that user uploaded across posts, projects, gists, comments, messages, issues, and news, newest first. Images open in the shared lightbox. The owner can delete their own media and an admin can delete anyone's; deletion is a **soft delete** that hides the item everywhere (including its parent object) while preserving the file and the relation, so an admin can restore it from the **Media** trash at `/admin/media`.
- **Reward notifications** fire when a member levels up or earns a badge.
- **AI content correction.** Opt-in, default off. When a member enables it on their profile, the prose they author (post titles and bodies, project and gist titles and descriptions, comments, direct messages, and their bio) is automatically rewritten by the AI gateway according to a member-defined instruction, using the member's own API key for per-user attribution. A member chooses the apply mode: **in background** (default; content is saved exactly as written and corrected a moment later, so the write path is never slowed) or **synchronously** (the save waits for the correction so the stored result is corrected immediately). The rewrite is fail-soft (the original is kept on any error) and applies identically across the web UI, the REST and devRant APIs, and Devii. Code and source files are never corrected. In direct messages the correction is delivered live: the corrected message appears in the chat for both participants without a reload. Configure it on your profile or via the Devii `ai_correction_set` tool; the settings are saved at `POST /profile/{username}/ai-correction`. Successful correction calls accumulate per-user running totals - corrections, token counts, cost, and timing/performance (average latency, average speed in tokens per second, and total processing time) - shown on the profile page; token, call, and performance figures are visible to the member, while the dollar figures (total and average cost) are shown to administrators only.
- **AI modifier.** Enabled by default and applied synchronously by default. It works like AI content correction, except it runs **only** where the prose you author contains an inline `@ai <instruction>` directive: the configured prompt tells the model to execute that instruction and replace the marked part, removing the `@ai` marker. Text with no `@ai ...` directive is left exactly as written. It is **context-aware**: the model is given a grounding summary of who is asking (your username, role, level, stars, post count, rank, followers, and bio), the current date, and where the directive sits - the post a comment replies to, the conversation a direct message belongs to, the gist's language and code, and so on - so directives like `@ai answer the question above`, `@ai write my bio from my stats`, or `@ai reply to this` work. It uses your own API key for per-user attribution, is fail-soft (the original is kept on any error), and applies across the web UI, the REST and devRant APIs, and Devii, on the same prose fields as correction (posts, projects, gists, comments, direct messages, and your bio). Code and source files are never touched. In direct messages it runs live: typing `@ai <instruction>` in a message executes it and the resolved result appears in the chat for both participants without a reload. You can switch the apply mode to background or disable it on your profile or via the Devii `ai_modifier_set` tool; the settings are saved at `POST /profile/{username}/ai-modifier`. The default instruction is "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`". Successful modifications accumulate per-user running totals - modifications, token counts, cost, and timing/performance (average latency, average speed in tokens per second, and total processing time) - shown on the profile page; token, call, and performance figures are visible to the member, while the dollar figures (total and average cost) are shown to administrators only.
- **Devii interactive widgets.** Administrators set the site default on the Devii service (`devii_interactions_default`, default on). Guests always use that default. Signed-in members inherit it until they override it on their profile or via the Devii `interactions_set` tool (`POST /profile/{username}/interactions`; owner or admin). When enabled, Devii may present decisions with channel-aware controls (`ui_prompt`); when disabled, it falls back to plain numbered menus.
Every AI gateway response (`/openai/v1/*`) also returns per-call `X-Gateway-*` headers with the full token breakdown and the dollar cost of that call, so any client can read its own usage.
## Quizzes
**Quizzes** (`/quizzes`) let any signed-in member write a quiz and every member play it. The hub is
one page with three columns: filters and search on the left, the quiz list in the middle showing
what you still have to do and what you already completed with your score, and the cross-quiz
scoreboard on the right. Guests read published quizzes and see the board; they cannot play.
- **Eight question kinds.** Single choice, multiple choice, true/false, free text, fill in the
blanks, numeric, ordering, and matching. Seven are graded deterministically, several with partial
credit. Ordering and matching use plain selectors and keyboard controls, never a drag-only
interaction, so they work with a keyboard and a screen reader.
- **AI-graded free text.** A free-text answer is reviewed by the platform's own AI against the
author's reference answer and grading criteria, billed to the answering member's own API key. The
score is re-clamped on the server and the correct/incorrect verdict is derived from the clamped
score, so a reviewer can never mark an answer correct while scoring it zero. When the reviewer is
unavailable the answer is still graded, by a deterministic keyword comparison, and is visibly
stamped as such - grading never silently becomes a zero.
- **Publishing is permanent.** A draft is fully editable; publishing freezes the quiz, its
questions and its options forever. There is no unpublish and no post-publish edit, which is what
makes two members' scores on the same quiz comparable. The builder shows a live pre-publish
checklist and keeps the Publish button disabled until it is empty, and the action is confirmation
gated on both the web UI and in Devii.
- **One attempt at a time.** Starting a quiz creates an attempt that lives on the server, so a
refresh, a second tab and a different device all resume the same one. Each question can be
answered exactly once. A time limit is a deadline stored on the attempt, evaluated when someone
looks at it - nothing runs in the background.
- **Settings.** Shuffle the questions, shuffle the options, reveal the correct answer after each
question, allow reviewing every answer on the results screen, set a time limit, set a pass mark.
- **An honest scoreboard.** Your **best** attempt per quiz counts, never the sum of your attempts,
so replaying a quiz can raise your contribution up to your personal best and never beyond it.
Quizzes you wrote yourself count like any other. Each quiz also has its own leaderboard.
- **Full automation.** Devii creates a complete quiz from one JSON document, publishes it, plays it
end to end and reads the result, all through the same public API - and the hub's *Create quiz
with Devii* button opens the assistant with that request already typed in (it never sends it for
you). The whole flow works without JavaScript too: every question is a real form.
- **Engagement.** Quizzes carry comments, stars, bookmarks and reactions like any other content,
and appear in the sitemap.
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
`devplace quiz prune`.
## Code Farm
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.
- **Plant.** Plant a software project (a shell script, Python script, web app, Go service, Rust engine, compiler, or kernel) in an empty plot for a coin cost. Higher-tier crops unlock as your farm level rises.
- **Build and harvest.** A planted crop builds over real time; when the build finishes, harvest it for coins and XP. Harvesting also awards site XP and the **Green Thumb** / **Master Farmer** badges.
- **Upgrade CI.** Spend coins to raise your CI tier (Local Build through Distributed Cache); each tier makes every build faster.
- **Buy plots.** Unlock more plots (up to twelve); each new plot costs more than the last.
- **Fertilize.** Spend coins on a growing build to halve its remaining time. The cost is priced against the build's realized harvest value, so fertilizing is a pure time-skip - it brings the harvest sooner but never returns more coins than it costs, at any prestige level.
- **Daily bonus.** Claim a coin bonus once per day; consecutive days build a streak that grows the reward (capped at seven days).
- **Daily quests.** Three quests rotate every day (plant, harvest, water, or earn goals), tracked automatically as you play; claim each one for coins and XP when complete.
- **Perks.** Spend coins on four permanent upgrades - Optimizer (+harvest coins), Build Cache (+build speed), Bulk Licenses (-planting cost), and Mentorship (+harvest XP) - each levelling up with escalating cost.
- **Refactor (prestige).** At level 10 you can refactor: the farm resets (coins, level, CI, extra plots, perks) in exchange for a permanent +25% coin bonus that stacks with every refactor. Each refactor also awards **Stars** (scaled by the level and prestige you reached) to spend in the Legacy shop. Refactoring costs a **dynamic coin fee** that grows with your prestige and current wealth, so you must farm well past level 10 to afford each one - and 10% of what remains after the fee (more with the Golden Parachute Legacy upgrade, up to 60%) carries over into the new run.
- **Community treasury and weekly grant.** Every refactor fee flows into a shared treasury. Active low-balance, low-prestige farms (at least five harvests this week, under 10,000 coins, at most prestige 5) can claim a grant from it once per week - the balance is divided between everyone currently eligible rather than paid first-come-first-served, capped at 2,500 coins and suppressed below 250. A direct wealth transfer from the farms refactoring at the top to the farms building at the bottom.
- **Stars and Legacy (endgame).** Stars buy permanent **Legacy** upgrades that survive every refactor, unlike perks: **CI Bot** (auto-collects ready builds when you open your farm), **Tech Debt Payoff** (+coins, stacks with prestige), **Bare-Metal** (+base build speed), **Monorepo** (+starting plots after each refactor), **Branch Protection** (longer steal grace and a smaller steal cut), and **Golden Parachute** (a larger refactor coin carry-over). This is the infinite progression for maxed farms, and CI Bot makes the game playable hands-off.
- **Golden builds.** A small share of plantings come out golden (marked with a sparkle); harvesting a golden build pays several times the coins.
- **Visit and water friends.** Open another member's farm at `/game/farm/{username}` and water their growing builds to speed them up - you earn coins for helping - scaled by your own prestige and Tech Debt Payoff multiplier, so the cooperative loop stays worth doing at every stage - and the owner sees the help live. This is the social loop that makes the game cooperative.
- **Steal a harvest.** A ready build on someone else's farm can be stolen once a protection window passes - the owner gets that grace period (longer if they invested in Branch Protection or a Defense building) to harvest it first. A successful raid pays the thief a share of the build's coin value and the **owner keeps and can still harvest the remainder** - a raid redistributes value rather than destroying it. The thief earns the **Cat Burglar** badge; the victim gets the **Robbed** badge and a notification naming the raider, the crop, and the exact amount taken. You can raid any given neighbour only **once per hour**, and any farm can absorb at most **3 raids per day**, so an inactive player can never be stripped by an unlimited queue of raiders. Raiding a farm with 10x your own coins grants a 24-hour **Underdog** boost (+25% coin gain) and the **David vs Goliath** badge. Stealing pays coins only, so the harvest-based leaderboards stay earned by real farming.
- **Market Saturation.** The last 48 hours of league-wide harvests of each crop are tracked and converted into grow-time-normalized supply, so fast and slow crops saturate on the same real-terms scale; supply is measured per active farm so a busy server is not permanently floored by a few heavy players; when a crop is over-farmed its payout drops in steps (down to 40%), while the four starter crops pay a boost (up to +15%) whenever the high-tier market is saturated and they are not - a crop is either penalized or boosted, never both. Printing one crop nonstop is throttled, planting what the market is short on is rewarded. The shop shows a live "Saturated" / "Boosted" label per crop.
- **Infrastructure.** Permanent, expensive, prestige-gated buildings and coin sinks: **Private Registry** (faster Rust/Compiler/Kernel builds), **Canary Deployments** (a chance to double or only refund a harvest), and **Observability Suite** (caps what any raider can take from you at 20% of a build's value).
- **Defense.** An upgradeable building that multiplicatively reduces raid losses and adds steal grace - but costs an ongoing daily coin upkeep (proportional to your coin balance, so it scales with wealth). If you cannot pay, only what you can afford is taken and the tier decays by one level - your balance is never emptied - and you are notified. You can also step down a tier deliberately to leave the commitment.
- **Cosmetics.** Purely cosmetic titles and plot skins, bought with coins - zero gameplay effect, pure status. An equipped title shows next to your name on the leaderboard.
- **Mastery (endgame beyond prestige).** From prestige 50 onward, every 5 more prestige earns a permanent Mastery point (spendable, and the milestone itself never re-locks). Mastery upgrades open new gameplay instead of bigger numbers: **Continuous Delivery** (auto-replant after harvest), **Farm Analytics** (lifetime stats on your HUD), and **Legacy Contracts** (a weekly long-term contract slot paying Stars and a temporary coin boost). Reaching Mastery also unlocks three new high-tier crop families (Distributed System, ML Pipeline, Security Fortress - the last one immune to raids).
- **Leaderboards.** Several boards, selectable from the game page: **Overall score** (a composite weighing refactor/prestige count, XP, lifetime harvests, a capped coin contribution, CI tier, plots, perks, and streak - the cap keeps it a measure of what you built rather than what you hoard), **Prestige**, **Harvests this week**, **Raid efficiency** (average coins per successful raid), **Fastest to Kernel** (time since your last refactor), **Fair play** (rewards recent activity over hoarding), and (when running) the current **Era** board.
- **Eras (admin-managed seasons).** Administrators can start an Era at `/admin/game`: every farm's *visible* Era coins/harvests counters reset to zero, but real coin balances, prestige, Stars, Legacy, and Mastery are never touched. Ending an Era ranks farms by Era score (which gives prestige only partial weight, so veterans keep an edge without it being insurmountable), awards Stars to the top 10, and permanently records the results.
The farm refreshes live over the pub/sub bus (a watered build appears on the owner's screen at once) and every plot countdown ticks client-side. Every endpoint also answers JSON, and Devii can play the game on the member's behalf via the `game_*` tools (`game_state`, `game_plant`, `game_harvest`, `game_buy_plot`, `game_upgrade_ci`, `game_fertilize`, `game_daily`, `game_claim_quest`, `game_water`, `game_steal`, `game_view_farm`, `game_leaderboard`, `game_upgrade_perk`, `game_upgrade_legacy`, `game_prestige`, `game_claim_grant`, `game_upgrade_mastery`, `game_buy_infrastructure`, `game_upgrade_defense`, `game_buy_cosmetic`, `game_equip_cosmetic`, `game_downgrade_defense`). See the API reference group **Code Farm** and the full player guide at `/docs/code-farm.html`.
## Engagement
- **Emoji reactions** - a fixed palette of reactions on posts, comments, gists, and projects, separate from voting and carrying no ranking weight.
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils/`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
## Trust and safety
DevPlace is open in the sense that it does not editorialise technical opinion. It enforces a short, fixed list of prohibited categories, published as the [Community Guidelines](/docs/community-guidelines.html), and it has the machinery to make that enforcement real.
- **Automated filtering at post time.** Every user-authored text passes a classifier at the five places content is created or changed (content creation, content editing, comments, direct messages, profile and signup fields), covering every surface with no per-route code. The default mode is `review`, not `block`: a match is published **and** raises a report for a human, because a developer platform discusses exploits, malware analysis and violent subject matter as its work and a machine that suppressed that would destroy the product. Only sexual and exploitative content is refused outright. Classification failure falls back to review, never to publication. Tunable live at `/admin/settings` (`moderation_filter_mode`: `off`, `label`, `review`, `block`).
- **A report control on every surface.** Posts, comments, gists, projects, project files, news, uploads, direct messages, quizzes, polls, awards, profiles, issues, workspaces and assistant output are all reportable through one polymorphic endpoint keyed on `(target_type, target_uid)`, from one dialog, with one reason list. Reports are private to the reporter; the reported person is never told who filed.
- **One queue with a published response window.** `/admin/moderation` is worked oldest-open-first and carries a badge showing the age of the oldest unresolved report against `moderation_sla_hours` (default 24), so a breach is visible rather than assumed. Resolution is a single atomic conditional update: two administrators deciding at once produce exactly one decision, the second gets a 409.
- **Real enforcement, with a statement of reasons.** Remove or restore content, warn, suspend for a stated period, ban, lift, dismiss, or escalate. Every decision writes a permanent `moderation_actions` row plus an audit event, and tells the affected user what was decided and why. A suspended account can still read, still see why, still report, and still delete itself; it cannot create. A junior administrator can never action a senior one.
- **Blocking**, unchanged and independent of all of the above, is now reachable from the content itself as well as from a profile.
- **Age and maturity.** Signup collects a date of birth, derives an age band, and **discards the date**; accounts below `moderation_minimum_age` (default 16) are refused. Content labelled mature is hidden behind an interstitial until the viewer explicitly opts in, and the reveal is never offered to a minor age band for restricted content.
- **Consent.** Five versioned, independently withdrawable consents (`terms`, `privacy`, `ai_third_party`, `activity_recording`, `container_credentials`) with full history, managed at `/profile/{username}?tab=privacy` and changeable **only by the account holder** - an administrator reads the record but never grants or withdraws on someone else's behalf. **No content is sent to a third-party AI provider without `ai_third_party` consent**, enforced once at the gateway; the per-feature AI toggles remain as preferences subordinate to it. Withdrawing `activity_recording` stops presence recording. While recording is on, an indicator says so on every page. `container_credentials` is what lets software another member runs in a container receive your API key; running your own container never asks.
- **Account deletion.** Self-service at `/profile/{username}/delete`, reauthenticated with the account password. Sessions and tokens are revoked, the profile is anonymised immediately, and all content is removed under one deletion event that stays restorable for `account_deletion_grace_hours` (default 24) before `devplace accounts prune` (and the Moderation housekeeping service) purges it permanently. The devRant `DELETE /api/users/me` routes into the same cascade.
- **Legal pages**: [Terms of Service](/docs/terms.html), [Community Guidelines](/docs/community-guidelines.html), [Privacy Policy](/docs/privacy.html), [How moderation works](/docs/content-moderation.html), [Notice and takedown](/docs/intellectual-property.html) and [Contact](/docs/contact.html). All six are indexed in the sitemap and reachable from the docs index; the footer of every page links Terms, Privacy, Community Guidelines and Contact. Contact details come from the `contact_email`, `contact_phone` and `contact_address` settings, so the in-product page and any app-store trader declaration have one source of truth.
## Vibe coding (Alpha, admin only)
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`, and `DEVPLACE_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
## Admin: Audit Log
@@ -132,6 +245,10 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | Cache-busting version stamped into every static asset URL (`/static/v<version>/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
| `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin before an online user drops to offline (hysteresis): online at the timeout, offline only after timeout + this. Prevents online/offline flicker for users hovering at the boundary |
| `DEVPLACE_PRESENCE_TRACK_LIMIT` | `500` | Size of the online set the presence relay tracks and publishes on `public.presence.roster`. That one set is the single source of truth behind every avatar presence dot on every page; `DEVPLACE_PRESENCE_ONLINE_LIMIT` only caps how many of them the feed's Online now panel displays |
### Runtime settings
@@ -150,6 +267,7 @@ Operational behavior is tunable live from `/admin/settings` (stored in `site_set
| `customization_enabled` | `1` | When `0`, no user CSS/JS customization is injected on any page |
| `customization_js_enabled` | `1` | When `0`, user custom CSS is still served but custom JavaScript is suppressed |
| `audit_log_retention_days` | `90` | Audit rows older than this are pruned daily by the Audit retention service; `0` disables pruning |
| `extra_head` | empty | Raw HTML injected into every page `<head>` (custom `<style>`, `<script>`, `<link>`, meta tags, or analytics snippet). Site-wide, trusted-admin input, not sanitized |
Numeric values are floored to safe minimums so an invalid entry cannot lock out writes or stall services. Consumers read via `get_setting`/`get_int_setting`, which fall back to these defaults when a row is absent.
@@ -157,7 +275,7 @@ Numeric values are floored to safe minimums so an invalid entry cannot lock out
The website uses a `session` cookie. For automation, every page and action also
accepts three header-based methods, resolved centrally in `get_current_user`
(`utils.py`) so they work everywhere with no per-route changes:
(`utils/`) so they work everywhere with no per-route changes:
- **API key** - `X-API-KEY: <key>`
- **Bearer** - `Authorization: Bearer <key>`
@@ -183,7 +301,7 @@ Every endpoint that renders a page or returns a redirect also speaks JSON, so an
website does is automatable from the same URLs. A request gets JSON when it sends
`Accept: application/json` or `Content-Type: application/json`; a normal browser navigation
(`Accept: text/html`) always gets HTML, so existing behaviour is unchanged (the legacy
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas.py` and built
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas/` and built
from the same context the templates use (sensitive user fields like `email`/`api_key`/
`password_hash` are never exposed). Page GETs return the page payload; form actions return a
uniform envelope `{ "ok": true, "redirect": "…", "data": {…} }`; errors return
@@ -191,6 +309,10 @@ uniform envelope `{ "ok": true, "redirect": "…", "data": {…} }`; errors retu
JSON → `401`, non-admin → `403`). The core lives in `devplacepy/responses.py`
(`wants_json`, `respond`, `action_result`). Full details: `/docs/conventions.html`.
Every response carries an `X-Response-Time: <ms>ms` header (set by the outermost `response_timing`
middleware, the full request total), and every rendered HTML page shows that server render time as a
small fixed indicator in the bottom-left corner.
```bash
curl -H "Accept: application/json" https://your-host/feed
curl -H "Accept: application/json" -X POST -d "content=hi&title=T&topic=devlog" https://your-host/posts/create
@@ -253,7 +375,7 @@ audit, and soft-delete all apply.
| GET / POST / DELETE | `/api/comments/{id}` | Read / edit / delete a comment |
| POST | `/api/comments/{id}/vote` | Vote on a comment |
| GET / DELETE | `/api/users/me/notif-feed` | Notification feed / mark all read |
| GET | `/api/avatars/u/{username}.png` | PNG avatar rendered from the username seed |
| GET | `/api/avatars/u/{seed}.png` | PNG avatar rendered from the user's avatar seed (the regenerated `avatar_seed`, or the username when unset) |
devRant `tags` round-trip verbatim via a `tags` column on `posts`; `profile_skills` is derived
from the user bio (DevPlace has no separate skills field); avatars are real PNGs rendered from
@@ -309,20 +431,66 @@ and its full configuration are documented automatically - including future servi
- **`ConfigField`** - declarative parameter spec (type, default, validation, secret) a service uses to declare its editable settings
- **`BaseService`** - abstract class with a reconciling run loop that honors the persisted `enabled`/command/interval state, plus `config_fields`, `get_config()`, `describe()`, and a log buffer
- **`ServiceManager`** - singleton: `register`, `describe_all`, `set_enabled`, `send_command`, `save_config`, `supervise`, `shutdown_all`
- **`NewsService`** - fetches news from `news.app.molodetz.nl/api`, grades with AI, stores articles >= threshold
- **`NewsService`** - a fully automatic, zero-maintenance news pipeline: fetches news from `news.app.molodetz.nl/api`, cleans each article, fetches and perceptually compares the images to reject placeholders and detect uniqueness, grades each one deterministically, reformats every valid article into clean Markdown (paragraphs, headings, lists) with the AI so the source wall of text reads as a proper article, and auto-rotates the best articles to Featured and the landing page. Its AI spend is metered from the gateway response headers and reported on the admin Services page (calls, tokens, total cost, and per-call averages)
- **`BotsService`** - Playwright fleet of AI personas that browse and interact with a DevPlace instance, with live cost/usage metrics and a live screenshot monitor at `/admin/bots` (opt-in; install the `bots` extra)
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call
- **`GatewayService`** - OpenAI-compatible LLM gateway at `/openai/v1/*`, forwarding to DeepSeek (default) with optional vision augmentation; the single point of truth for AI that every other service routes through (enabled by default). Every chat request is made date-aware by injecting the current date in EU `DD/MM/YYYY` format into the system message when it contains no date already (date only, never time, to keep upstream prompt caching effective), and an admin-configurable system preamble (`gateway_system_preamble`) can be prepended ahead of the client's system message on every call. **Provider and model routing** (admin **Gateway** page, `/admin/gateway`) maps any number of requested model names onto named upstream providers and target models, each with its own pricing economy (input, output and cache-hit/cache-miss rates per million tokens) and an optional vision model that describes image content before forwarding, so text and vision models are merged transparently. Unmapped requests fall through to the default upstream unchanged, so existing clients are unaffected
- **`JobService` / `ZipService` / `ForkService`** - generic async job framework (`services/jobs/`) for heavy, blocking work run off the request path; `ZipService` builds project zip archives in a subprocess, `ForkService` copies a project into a new project owned by the forking user
- **`ContainerService`** - the admin container manager (`services/containers/`): a reconciling supervisor for container instances, all running one shared prebuilt image
- **`AcceptanceService`** - grants every policy agreement (Terms of Service, Privacy Policy, third-party AI processing, activity recording, container credentials) to every account that has not declined it, so an instance kept production-identical for extended manual testing never interrupts with an acceptance dialog. Administrator-only, **off by default**, with a separate switch per agreement type and a dry run that reports what it would do without writing. An account that withdrew a consent is never granted it again, with no further action: the consent ledger itself is the decline register. It is not appropriate on a real production host
### Container manager (admin only)
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, and `pagent` at `/usr/bin/pagent.py` all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, `pagent` at `/usr/bin/pagent.py`, and the `code-server` browser IDE that backs dev workspaces all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
**Runtime data** (container workspaces and zip archives) lives in `DEVPLACE_DATA_DIR` (default `data/`), **outside the package and never served via `/static`**. The docker daemon must be able to bind-mount the data dir for `/app`.
### Dev Workspaces and the browser editor
A **workspace** is a member-facing container running the DevPlace browser editor, layered on the
container runtime above. It is opened from a project's **Workspace** page and reached at
`/projects/{slug}/workspace`; the editor itself is proxied at
`/projects/{slug}/containers/instances/{uid}/code/`, and an **Editor** button appears on the project
page whenever the workspace is running.
The editor is `code-server`, rebranded as DevPlace end to end: the application name, the browser tab
icon and PWA icons, the login page styling, and `product.json` all carry DevPlace, and a bundled
built-in extension ships the **DevPlace Dark** and **DevPlace Light** themes (generated from the
site's own design tokens), a **Get started on DevPlace** walkthrough, a project status bar item and
five `DevPlace:` commands. Nothing in the interface identifies as code-server.
**On boot** two terminals open: a focused **DevPlace Code** terminal already running `dpc`, the
coding agent baked into the image, and a plain login shell beside it with the Python, Rust, Nim and
Swift toolchains on `PATH`. Both are configurable, and `bash` stays the default profile for
terminals the member opens later.
The workspace opens straight onto the member's files rather than a welcome page, and the editor's
own built-in chat assistant is suppressed so `dpc` is the only agent on offer and every token it
spends is ledgered against the member's DevPlace account. `dpc`'s own working files (`.dpc/`,
`dpc.log`) are in `SYNC_SKIP_NAMES`, so running an agent on every boot never pollutes the project.
**Every workspace is trusted.** VS Code Restricted Mode is disabled at the command line and in the
seeded settings, so nothing prompts and automatic tasks run. This is a deliberate default with a
real consequence (a project's own `.vscode/tasks.json` will run on folder open), it is documented to
members on `/docs/workspace-editor.html`, and an administrator can restore Restricted Mode site-wide
with the `workspace_editor_trust_all` setting.
**Four sizes are configurable through one resolver.** Editor and terminal font size plus zoom, the
editor layout and terminal panel preset, whether the editor opens in a tab or a sized window, and the
container's CPU, memory and disk. The first three are the member's own preferences on their workspace
page (and over the API, and through Devii's `workspace_editor_get` / `workspace_editor_set`); the
container size is part of the administrator-set workspace quota. Each preference resolves instance
override, then the member's row, then the site setting, then the built-in default, and the page shows
which of those each value came from.
**A member edit is never overwritten.** DevPlace seeds the editor's `settings.json` from the host
before each launch and records exactly what it wrote; on the next launch it updates only the keys
whose current value is still the one it wrote. A setting the member changed inside the editor is
theirs permanently, while a change to the site default still reaches everyone who has expressed no
preference. Preferences apply on the next workspace start, and the page says so and offers the
restart.
### Async job framework and zip downloads
`services/jobs/` is the standard way to run blocking work asynchronously and hand the caller a result URL. A shared `jobs` table is the queue (discriminated by `kind`); `queue.enqueue()` inserts a `pending` row from any worker, the lock-owning worker processes jobs in `JobService.run_once` (reap, recover orphans, refill up to a concurrency limit, prune expired), and status is polled from the database. Retention is built in: each job service deletes its own expired artifacts via a `cleanup` hook (default 7 days, admin-configurable). To add a kind, subclass `JobService`, set `kind`, and implement `process()` and `cleanup()`.
@@ -333,7 +501,13 @@ and its full configuration are documented automatically - including future servi
`SeoService` powers the public **Tools -> SEO Diagnostics** auditor. It runs a headless-browser (Playwright) crawl of a single URL or a sitemap (capped pages) in a subprocess and runs a broad battery of checks across eleven categories: crawlability and indexing (status, redirects, HTTPS/HSTS, canonical, robots/meta-robots, sitemap, URL hygiene, mixed content), on-page meta and content (title, description, headings, language, charset, viewport, favicon, content depth), links, structured data and rich results (JSON-LD validity and required properties, microdata/RDFa), social cards (Open Graph, Twitter), Core Web Vitals and performance (LCP, CLS, FCP, TTFB, page weight, requests, DOM size, compression, caching, image optimisation, console errors), mobile and accessibility (responsive layout, tap targets, image alt, form labels), security headers, and AI/LLM-search readiness (server-rendered-vs-JS content parity, `llms.txt`, semantic HTML). It produces a weighted score and grade with per-category subscores and a recommendation for every finding. Progress streams live over `WS /tools/seo/{uid}/ws`; the full report is available at `/tools/seo/{uid}/report` (HTML or JSON). CLI: `devplace seo prune` / `devplace seo clear`. Playwright is a core dependency; `make install` fetches the Chromium browser.
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, crawls and reads the most relevant sources in a subprocess (plain HTTP first, headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded), de-duplicates content, and indexes everything into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (summarizer, critic, linker) then synthesises a cited report with a confidence score, source diversity and explicit gap analysis. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
`SeoMetaService` is a separate AI subservice that generates a clean, search-optimised title, description and short keyword list for every published post, project, gist, news article and issue, entirely off the request path so it never slows the web server. The work is queued whenever content is created, edited or published; until the AI value is ready a plain-content default (built from the markdown-stripped text and clamped to safe lengths) fills the fields, so a page's metadata is **always populated, never empty**. The service uses the built-in internal AI gateway and meters its own AI cost and statistics in a dedicated usage table, surfaced together with its live task pipeline on the **Admin -> Services** page. This release also fixes the on-page metadata: the `<meta name="description">` is now stripped of markdown markup (it previously leaked `#`, `**` and `[](...)` from the raw body), a `<meta name="keywords">` tag is emitted (a short honest list, not stuffed), and social-card image dimensions and alt text are added. CLI: `devplace seo-meta prune` / `devplace seo-meta clear` (job rows only; the generated metadata persists).
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, interleaves their results so every angle contributes sources, and crawls the most relevant pages in a subprocess (concurrent fetches, plain HTTP first with a headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded). Social sites that block bots (X, YouTube, Reddit and similar) contribute the readable text supplied by the search engine, so their content is not lost to a login wall. A readability-grade extractor isolates the main article content of each page (navigation, cookie banners and footers are discarded), and the configured depth follows the most relevant in-page links for deeper coverage. Content is de-duplicated and indexed into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (report writer, findings extractor, linker) then synthesises a thorough cited markdown report grounded on the passages retrieved from that index, with key findings, a confidence score and source diversity; if synthesis fails the report is clearly marked as degraded instead of silently shipping raw source material. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
`IsslopService` powers the public **Tools -> AI Usage Analyzer**, which classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Sources are acquired in an isolated subprocess (git URLs are probed with `git ls-remote` and shallow-cloned with a 3 GB guard; websites render in a stealth headless browser with an HTTP fallback, bounded by depth, file and byte caps), inventoried with exclusion rules, and scored by a multi-signal static engine (twenty-one detector families, 126 signal types across an origin axis and a quality-deficit axis). For a live website, the analyzer also opens the home page in a headless browser and inspects what actually renders: it fingerprints AI website-builder platforms directly (Lovable, Bolt.new, Framer and others) and checks the page's real computed styles, layout and build artifacts, not just its file contents. Representative files receive an AI review pass and images an AI-generation review through the internal gateway (model `molodetz`, internal key); the static engine stays authoritative when the gateway is unreachable. Every pipeline step is persisted as an ordered event trail and streamed live over the pub/sub topic `public.isslop.{uid}`. The verdict is an A-F authenticity grade with a human/AI split and one of five categories (`ai-slop`, `sophisticated-ai`, `human-clean`, `human-messy`, `uncertain`), published as a persistent report with an embeddable SVG badge. Members keep their analysis history on their account; guest history is session-bound and claimed by the account on the first signed-in visit. Admin settings (private-host allowance, AI/image review toggles, image cap, retention, concurrency) live on `/admin/services`. CLI: `devplace isslop analyze <url>` / `devplace isslop prune` / `devplace isslop clear`. Playwright plus playwright-stealth back the website crawler.
`BackupService` powers the admin **Admin -> Backups** dashboard, an enterprise-grade backup system that runs entirely as asynchronous jobs so it never impacts the running server. An administrator can back up one of four targets: the **database** (a consistent SQLite snapshot of the main database and the Devii task/lesson databases, taken with SQLite's online backup API so it is consistent under WAL), **uploads** (every attachment and project file), **keys and config** (VAPID keys), or the **full data directory** (database snapshot, uploads, and keys in one archive, excluding regenerable staging, locks, caches, and container workspaces). Each backup is compressed to a `tar.gz` in a stdlib subprocess off the request path and recorded with its size, file count, and a SHA-256 checksum. Archives live under `data/backups/` (sharded on the random uuid tail) and are served only through `/admin/backups/{uid}/download`, which is restricted to the **primary administrator** - the first user created with the Admin role. Every other administrator receives a 403 from the endpoint and sees the Download button disabled with the tooltip `Not available`; creating, running, deleting, and scheduling backups remain available to all administrators. The dashboard reports detailed storage usage - the size and file count of every major data area, the total data-directory footprint, the total size and count of stored backups, and disk usage (total, used, free, percent), computed in a worker thread and cached briefly so the page never blocks. Backups can be **scheduled** (CRUD) on an interval or 5-field cron expression with a `keep_last` rotation count that prunes older backups of the same schedule; the service evaluates schedules only on the lock-owning worker so each fires exactly once. Backup archives are permanent operational artifacts: job retention only removes the tracking row, never the archive, which is deleted only by an administrator, by schedule rotation, or via the CLI. CLI: `devplace backups list` / `devplace backups run <target>` / `devplace backups prune` / `devplace backups clear`. Devii tools: `backups_overview`, `backup_run`, `backup_status`, `backup_delete`, `backup_schedule_create`, `backup_schedule_delete` (all admin-only). The service creates and stores backups but does not restore them into a live server; restore is a documented manual procedure (stop the server, unpack the archive over the data directory, verify the checksum, restart).
### Adding a service
@@ -345,7 +519,16 @@ The service appears at `/admin/services` with controls, a generated config form,
### News service
Fetches news from a configurable API, grades each article via AI, stores ALL articles in the `news` table (nothing is silently skipped). Articles with grade >= the configurable threshold are auto-published; the rest go to draft. Admin can manually publish/draft, toggle for landing page appearance, and delete. Images are extracted from article URLs.
A fully automatic, zero-maintenance import pipeline. It fetches news from a configurable API and stores ALL articles in the `news` table (nothing is silently skipped). For each article it:
- **Cleans the text** - strips HTML and removes Reddit boilerplate (`submitted by /u/...`, `[link]`, `[comments]`, `[N comments]`) and collapses whitespace, before grading and before storage.
- **Fetches and perceptually compares the images** - up to five candidate images per article are downloaded through an SSRF-guarded client, decoded, and perceptually hashed. Images that are too small or fail to load are placeholders, and an image that appears across two or more different articles (a shared logo or stock placeholder) is rejected for all of them. An article with at least one genuinely unique image keeps it as its primary image.
- **Grades deterministically** - an AI model rates the cleaned article 1-10. A reliability gate forces a short, shouting, bodyless, or url-less article to draft. The final score adds a bonus for a unique image and a penalty for thin content, and that final score drives publishing.
- **Auto-promotes** - articles at or above the threshold are published; the strongest published articles with a unique image are marked **Featured**, and the service keeps the best of those on the landing page, rotating them automatically.
Admin can manually publish/draft, toggle Featured, toggle landing-page appearance, and delete; a manual Featured or landing toggle **locks** that article so the service no longer auto-manages it.
The Featured badge and the numeric Grade are editorial signals shown only to administrators. On the public news pages (listing, article, and landing-page cards) members and guests see only the article, its source, and its time; the badges remain visible to administrators and in the admin Manage News area.
Configuration on the Services tab (`/admin/services`):
@@ -389,11 +572,11 @@ Configuration on the Services tab:
| `bot_news_api` | `https://news.app.molodetz.nl/api` | Article source |
| `bot_model` | `molodetz` | Generic model name; the gateway maps it to the real model |
| `bot_api_key` | internal key | LLM key (defaults to the auto-generated gateway internal key) |
| `bot_input_cost_per_1m` / `bot_output_cost_per_1m` | `0.27` / `1.10` | Token pricing for live cost tracking |
| `bot_input_cost_per_1m` / `bot_output_cost_per_1m` | `0.14` / `0.28` | Fallback token pricing; used only when the LLM endpoint returns no gateway cost headers. Live cost is read from the gateway's authoritative `X-Gateway-Cost-USD` per-call header |
| `bot_max_per_article` | `2` | How many bots may post about one article, each from a different angle |
| `bot_article_ttl_days` | `7` | How long an article stays covered before it can be posted again |
| `bot_gist_min_lines` | `6` | Reject generated snippets shorter than this many non-empty lines |
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `30` | Idle pause window a bot takes after each action |
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `45` | Idle pause window a bot takes after each action |
| `bot_break_scale` | `1.0` | Multiplier on between-session breaks (below 1 = more active and costlier) |
| `bot_ai_decisions` | disabled | Let each bot's AI-generated identity decide every action via the LLM instead of fixed probabilities (one decision call per page); see `aibots.md` |
| `bot_decision_temperature` | `0.4` | Sampling temperature for the per-page decision call |
@@ -434,6 +617,17 @@ disclosed only to administrators, while members and guests can see only the perc
24-hour quota used. The `/devii/usage` endpoint returns that percentage and the day's turn count to
everyone, and includes dollar figures only for administrators.
**Spend limits (Gateway page, `/admin/gateway`).** An administrator caps the rolling 24-hour
gateway spend with quota rules scoped by any combination of caller role, individual user, and
application reference (the `X-App-Reference` header), so a single application belonging to one
user can be limited independently of that user's other traffic. The most specific matching rule
wins; a caller over its cap gets `429`. Because a cap otherwise only lifts with the passage of
time, each rule has a **Reset spend** action that clears what has been counted against it
without deleting anything from the usage history the cost analytics are built on - the
figures on the AI usage page stay intact, only the amount counted towards the limit is cleared.
The **Reset all quotas** button on the AI usage page clears the assistant quotas and the gateway
spend together. From the terminal: `devplace gateway quota list|set|delete|reset`.
Configuration on the Services tab:
| Parameter | Default | Purpose |
@@ -453,6 +647,7 @@ Configuration on the Services tab:
| `gateway_price_cache_hit_per_m` / `_cache_miss_per_m` / `_output_per_m` | 0.0028 / 0.14 / 0.28 | Chat cost per 1M tokens, used when the upstream returns no native cost (DeepSeek) |
| `gateway_vision_price_input_per_m` / `_output_per_m` | 0 / 0 | Vision cost per 1M tokens, used only when the vision upstream returns no native cost |
| `gateway_embed_price_input_per_m` | 0.01 | Embeddings cost per 1M input tokens, used only when the embeddings upstream returns no native cost |
| `gateway_rsearch_cost_per_call` | 0.0 | Flat cost attributed to each external `rsearch` call (web search / AI answer / chat / image describe), recorded under backend `rsearch` so external AI spend appears in AI usage |
| `gateway_max_retries` / `gateway_retry_backoff_ms` | 2 / 250 | Retry attempts and linear backoff on timeout, connection error, or upstream 5xx |
| `gateway_circuit_threshold` / `gateway_circuit_cooldown_seconds` | 5 / 30 | Consecutive failures before the circuit breaker opens, and its cooldown |
| `gateway_usage_retention_hours` | 720 | How long per-call usage rows are kept before pruning (30 days) |
@@ -470,8 +665,11 @@ shows live request/error/latency/vision-call counters.
Every upstream call (chat, vision, and passthrough) is recorded to `gateway_usage_ledger`
with its tokens, cost, latency breakdown, status, and caller. Cost is taken from the
upstream native `cost` field when present (OpenRouter) and computed from the configured
per-million pricing otherwise (DeepSeek, which reports no cost). The admin **AI usage**
page (`/admin/ai-usage`) reports per-hour and 24h metrics - request volume and throughput,
per-million pricing otherwise (DeepSeek, which reports no cost). External `rsearch` web
tools do not pass through the gateway upstream, so each call is also recorded to the same
ledger under backend `rsearch` (zero tokens, the flat `gateway_rsearch_cost_per_call`),
keeping the ledger a complete record of platform AI spend. The admin **AI usage**
page (`/admin/ai-usage`) reports per-hour, 24h, and **all-time** metrics - request volume and throughput,
token usage with averages and percentiles (p50/p90/p95/p99), latency (upstream round-trip,
gateway overhead, semaphore queue wait, connection establishment), error rates by category,
cost (per model, per caller, input vs output, projected monthly burn, caching savings),
@@ -508,13 +706,50 @@ Spend is capped per owner over a rolling 24 hours. Every turn appends a row to
`devii_usage_ledger` (the authoritative source for the cap - the in-memory cost tracker is
display-only) and an audit row to `devii_turns`. The cap is checked before each turn.
**Reminders and scheduled tasks.** Ask Devii to remind you of something ("remind me to go
upstairs in 40 seconds", "every weekday at 9am post the news"), and it schedules the work
instead of doing it immediately. A scheduled task stores a self-contained prompt that a fresh
agent runs when it fires - once after a delay or at an absolute time, on a repeating interval,
or on a cron expression. Reminders are **timezone-aware**: your browser's timezone is sent to
Devii and used to interpret wall-clock times you give ("3pm" means 3pm where you are),
converting them to UTC for storage. They are **persistent**: tasks live in `devii_tasks` and
are run by the background service, so a queued reminder survives a server restart and fires
even if you have closed the Devii terminal. When a reminder fires you receive an in-app
notification and a live toast carrying its message (the **Reminders** notification type, which
you can toggle like any other on your profile), in addition to the result appearing in the
terminal.
**Every account may schedule, within two rolling 24-hour quotas.** A member may create 5 tasks
and execute 10 task runs per 24 hours; an administrator may create 5 and execute 100. Deleting a
task does not give a creation slot back, and a run that would exceed the quota is **postponed
until a slot frees, never dropped or disabled** - the task simply runs later, and the exact time
its next slot opens is reported. All four numbers are adjustable on the Devii service page, where
0 means unlimited. Guests cannot schedule at all.
**A task knows when it is running as a task, and a member's task cannot spawn more tasks.** While
a scheduled run is executing, creating a task, re-enabling one, or triggering one immediately is
refused for members - so a member's automation can never fan out into more automation. An
administrator's task may schedule follow-up work, and every new task and run still counts against
the same quotas. The assistant is told which environment it is in, and the restriction itself is
enforced by the server rather than by the instruction, so no prompt can talk its way around it.
Every scheduled task is also bounded in time: a repeating task must leave at least fifteen minutes
between runs, carries a maximum number of executions, and expires at most thirty days after its
first run. A task that fails several times in a row, whose owner has been inactive for a month, or
that passes its automation spend limit is disabled automatically with the reason recorded in the
audit log. Across the whole platform only a few scheduled tasks run at the same time, handed out
one at a time per owner, so a single account can never monopolise the scheduler. Administrators
see every task, its owner, its 24-hour usage, and its bounds at **Admin -> Devii tasks**, where any
task can be disabled or deleted, and the same is available from the command line with
`devplace devii tasks`.
Configuration on the Services tab:
| Parameter | Default | Purpose |
|-----------|---------|---------|
| `devii_ai_url` | `https://openai.app.molodetz.nl/v1/chat/completions` | OpenAI-compatible reasoning endpoint |
| `devii_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | OpenAI-compatible reasoning endpoint (defaults to the internal gateway) |
| `devii_ai_model` | `molodetz` | Model name |
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`) | AI API key |
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`), then the gateway internal key | AI API key |
| `devii_base_url` | this instance's origin | Platform Devii drives via each user's API key |
| `devii_plan_required` / `devii_verify_required` | on / on | Enforce plan-first and verify-after-mutation |
| `devii_max_iterations` | `40` | Tool-loop iterations per turn |
@@ -528,6 +763,17 @@ Configuration on the Services tab:
| `devii_rsearch_enabled` | on | Enable the external web search tools (`rsearch_*`) |
| `devii_rsearch_url` | `https://rsearch.app.molodetz.nl` | Base URL of the web search service those tools call |
| `devii_rsearch_timeout` | `300` | Read timeout (seconds) for `rsearch_*` calls; web-grounded answers can take minutes; minimum five minutes |
| `devii_email_enabled` | on | Enable the email tools (`email_*`) for signed-in users |
| `devii_email_timeout` | `30` | Connection/read timeout (seconds) for IMAP and SMTP calls |
| `devii_task_member_create_24h` | `5` | Tasks a member may create per rolling 24 hours (`0` = unlimited) |
| `devii_task_member_runs_24h` | `10` | Task runs a member may execute per rolling 24 hours; excess runs are postponed |
| `devii_task_admin_create_24h` | `5` | Tasks an administrator may create per rolling 24 hours |
| `devii_task_admin_runs_24h` | `100` | Task runs an administrator may execute per rolling 24 hours |
| `devii_task_max_concurrent` | `4` | Scheduled tasks running at once across all owners, handed out round-robin, one at a time per owner |
| `devii_task_max_per_owner` | `10` | Active scheduled tasks one administrator may hold (`0` = no cap) |
| `devii_task_daily_usd` | `0.5` | Rolling 24h spend cap for scheduled runs, separate from the interactive quota (`0` = unlimited) |
| `devii_task_max_failures` | `3` | Consecutive failures after which a task disables itself (`0` = never) |
| `devii_task_owner_idle_days` | `30` | Disable an owner's tasks after this many days without activity (`0` = never) |
Beyond the platform tools, Devii has external **web** tools. `fetch_url` reads a web page;
`http_request` makes an arbitrary HTTP call (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) to any
@@ -544,6 +790,18 @@ preferred; Devii uses them only when the user explicitly asks to search the web
source. They are gated by `devii_rsearch_enabled` and the service URL is configurable via
`devii_rsearch_url`.
A signed-in user's Devii can also connect to their **own email** over IMAP and SMTP. The user
configures one or more accounts conversationally (`email_account_set` stores a connection under a
label - host, port, username, password, with sensible defaults of IMAP 993 over SSL and SMTP 587
with STARTTLS), and Devii can then list folders, list and search messages, read a full message with
its attachments, mark messages read or flagged, move messages between folders, delete messages, and
send mail. Credentials are stored per user and never shown back (the password is only reported as
set or not), the mail server is SSRF-guarded against private and loopback addresses, sending is an
explicit action Devii confirms with the user first, and deleting a message or removing a saved
account is confirmation-gated. The tools are gated by `devii_email_enabled` and time out per
`devii_email_timeout`. Email is configured only through Devii (no separate settings page) and is
available to signed-in users, not guests.
A `devii` console script ships the same agent as an interactive terminal:
```bash
@@ -552,6 +810,36 @@ devii --api-key <your DevPlace api_key> --base-url https://your-host
devii -p "List my unread notifications as a bullet list." # one-shot
```
### Devii on Telegram
`TelegramService` (`devplacepy/services/telegram/`) puts Devii on Telegram. A user pairs
their Telegram account by requesting a four digit code from their profile (valid one hour by
default), then sends that code to the bot; once paired, chatting with the bot talks to their
own Devii exactly like the web terminal, with markdown replies, a typing indicator, live
message editing instead of message spam, and image understanding (a sent photo is read by the
gateway vision model). The Telegram thread is an isolated conversation but shares the user's
Devii memory, tools, and the same rolling 24 hour spend cap.
The service is **off by default** (not every deployment has a bot token) and is started,
stopped, configured, and monitored from `/admin/services` like any other background service.
Its operational log streams live on the service detail page and is never written to the
database. The Telegram long-poller runs as a supervised subprocess so it stays isolated from
the web workers; because only the background-service lock owner runs the service, there is
always exactly one poller (Telegram rejects concurrent polling per token).
Devii also gains a `telegram_send` tool (only usable by a signed-in, paired user) so it can
push a message to the user's Telegram from a turn or a scheduled task - the basis for future
Telegram notifications.
Configuration on the Services tab:
| Parameter | Default | Purpose |
|-----------|---------|---------|
| `telegram_bot_token` | (secret) | Bot token from @BotFather; required to start |
| `telegram_poll_timeout` | `25` | getUpdates long-poll hold time (seconds) |
| `telegram_code_ttl_minutes` | `60` | Pairing code lifetime |
| `telegram_max_concurrent_turns` | `8` | Upper bound on Devii turns across all chats |
### Site customization (per-user CSS/JS)
Each user can reshape the site to taste by injecting their own **CSS** (look) and
@@ -597,15 +885,45 @@ calling itself.
## Push notifications & PWA
Authenticated users can receive native web push notifications, and the site is an
Authenticated users can receive native push notifications, and the site is an
installable Progressive Web App. Push uses only standard libraries (`cryptography`,
`PyJWT`, `httpx`) against the Web Push Protocol - no third-party push wrapper.
`PyJWT`, `httpx`) against the Web Push Protocol and the Apple Push Notification service -
no third-party push wrapper.
### Providers
Delivery is split into providers behind one protocol (`devplacepy/push/providers/`). A user
receives a notification through every provider they hold a live subscription for.
| Provider | Registration | Transport |
|----------|--------------|-----------|
| `webpush` | `PushSubscription` from the browser `PushManager` (endpoint + `p256dh`/`auth` keys) | Web Push Protocol, VAPID signed, `aesgcm` encrypted payload |
| `apns` | Hexadecimal device token | `POST https://api.push.apple.com/3/device/{token}` over HTTP/2, ES256 provider token |
`POST /push.json` accepts a registration for any active provider; a body without a
`provider` field is a `webpush` body, so browsers need no change. `GET /push.json` returns
the VAPID public key plus the providers currently accepting registrations. A provider that
is disabled or not fully configured accepts no registrations and is skipped during
delivery, so an unconfigured provider is inert rather than an error.
Every provider setting is edited at **`/admin/services/push`**: per provider an `Enabled`
toggle, the VAPID subject for `webpush`, and team id, key id, `.p8` auth key (stored as a
masked secret), topic and environment (production or sandbox) for `apns`. The same page
holds the shared delivery timeout and the retention window after which dead subscriptions
are removed. Push delivery does not depend on that service running; stopping it only stops
the pruning sweep.
Adding a third provider is one file plus one registry entry: the registration route, the
delivery loop, the admin page, the audit record and the metrics are all written against the
provider protocol.
### Events
Every event flows through a single funnel - `create_notification()` in `utils.py` -
which delivers on two independent channels, in-app and web push, each gated by the
recipient's preferences (see "Configurable notifications" below):
Every event flows through a single funnel - `create_notification()` in `utils/` -
which delivers on three independent channels, in-app, web push and Telegram, each gated by the
recipient's preferences (see "Configurable notifications" below). Whenever the in-app
channel delivers, the recipient's open browser also raises a live, click-through toast
in real time, bridged onto the in-process pub/sub bus by a lock-owner relay:
| Event | Recipient |
|-------|-----------|
@@ -620,25 +938,40 @@ recipient's preferences (see "Configurable notifications" below):
`create_notification` schedules delivery as a fire-and-forget async task, so a dead
subscription or push-service error never blocks the triggering request. Delivery
(`push.notify_user`) iterates a user's subscriptions, encrypts the payload
(legacy `aesgcm` content encoding), and POSTs to each endpoint; subscriptions that
return `404`/`410` are soft-deleted.
(`push.notify_user`) reads a user's subscriptions once, groups them by provider, builds
each provider's payload once, and sends over a single shared HTTP client. A subscription
the push service reports as gone (`404`/`410` for Web Push, `410` or an `Unregistered`
class reason for APNs) is soft-deleted; any other failure is logged and the subscription is
kept.
A notification is also **marked read automatically when you open the page that shows its
content** - viewing a post clears its comment, reply, upvote and mention notifications;
opening a conversation clears its direct-message notifications; visiting a profile clears
the matching follow, badge and level notifications; and the issue, reminder and farm-raid
notifications clear on their respective pages. You no longer have to dismiss each one by
hand after reading the content it points to.
### Configurable notifications
Every notification type can be turned on or off per channel, per user. The **Notifications**
tab on a profile page (`/profile/{username}?tab=notifications`, visible to the profile owner
and to admins) shows one row per type with two checkboxes - **In-app** and **Push** - saved
individually as you toggle them (`POST /profile/{username}/notifications`). A "Reset to
defaults" button clears all of a user's overrides (`POST /profile/{username}/notifications/reset`).
and to admins) shows one row per type with three checkboxes - **In-app**, **Push** and
**Telegram** - saved individually as you toggle them (`POST /profile/{username}/notifications`).
A "Reset to defaults" button clears all of a user's overrides
(`POST /profile/{username}/notifications/reset`). The Telegram column is disabled until the
user pairs Telegram from the profile Telegram panel; once paired, opting a type in delivers
that notification to the user's Telegram chat.
Defaults are opt-out: a type/channel a user never touched is enabled. Admins set the
platform-wide default for each type/channel on `/admin/notifications`
(`POST /admin/notifications`); a default applies only to users who have not made an explicit
choice. Resolution is: user override, else admin default, else on. Preferences are stored in
the `notification_preferences` table (per `user_uid` + `notification_type`, soft-deletable)
and enforced inside `create_notification()`: the in-app row is written only when the in-app
channel is enabled, and `push.notify_user` is scheduled only when the push channel is enabled.
Defaults are opt-out for in-app and push (a type/channel a user never touched is enabled) and
opt-in for Telegram (every type is off by default). Admins set the platform-wide default for
each type/channel on `/admin/notifications` (`POST /admin/notifications`); a default applies
only to users who have not made an explicit choice. Resolution is: user override, else admin
default, else the channel fallback. Preferences are stored in the `notification_preferences`
table (per `user_uid` + `notification_type`, soft-deletable) and enforced inside
`create_notification()`: the in-app row is written only when the in-app channel is enabled,
`push.notify_user` is scheduled only when the push channel is enabled, and a Telegram message
is queued (to the `telegram_outbox`, drained on the service-lock owner where the bot runs)
only when the Telegram channel is enabled and the user is paired.
### VAPID keys
@@ -659,17 +992,19 @@ every page load. `PushManager.js` owns registration, subscription, and the opt-i
### PWA
`manifest.json` (192/512 and maskable icons), `service-worker.js`, and an install
button (`PwaInstaller.js`) make the app installable. The service worker uses a
`manifest.json` (192/512 and maskable icons) and `service-worker.js` make the app
installable via the browser's native install affordance. The service worker uses a
network-first strategy for navigations and falls back to `static/offline.html` when
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
| File | Role |
|------|------|
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
| `devplacepy/push/providers/` | Provider protocol, Web Push (VAPID keys, payload encryption), APNs |
| `devplacepy/push/store.py` | `push_registration` reads and writes |
| `devplacepy/push/delivery.py` | `notify_user` - group by provider, deliver, reap dead subscriptions |
| `devplacepy/services/push/service.py` | Provider configuration at `/admin/services/push`, retention sweep, metrics |
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
| `static/js/PwaInstaller.js` | `beforeinstallprompt` capture + install button |
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
| `static/manifest.json` | PWA manifest (icons, display, theme) |
| `static/offline.html` | Offline fallback page |
@@ -697,9 +1032,25 @@ Removing a record is a **soft delete**, not a physical one: it stamps `deleted_a
A member may delete only their own content, but an **administrator may delete any member's** post, comment, gist, project, project file, or attachment - the owner-or-admin check lives on each delete endpoint, so it applies equally to the web UI and to the Devii assistant (which acts purely through the platform API as the signed-in user). When an admin's Devii is asked to delete something it requires explicit confirmation before each deletion, and the result is the same soft delete, restorable from Trash.
### Database API (`/dbapi`, primary administrator only)
The **primary administrator** (the oldest Admin account, the same identity that may download backups) authenticated by session or their API key can **read** any table through a single, safe API; members, guests, every other administrator, and internal/service callers (the gateway internal key is not accepted) all get `403`. The database API is **strictly read-only** - it can never insert, update, replace, delete, or restore data in any way.
- **Read per table:** `GET /dbapi/{table}` (filtered, searchable, keyset pagination) and `GET /dbapi/{table}/{key}/{value}`. There are no write endpoints; deny-listed tables (sessions, password resets) are never exposed.
- **`query()` is read-only:** `POST /dbapi/query` runs a single validated SELECT and returns rows. Every query is parsed (sqlglot), classified, and dry-run with `EXPLAIN` on a read-only connection before execution; non-SELECT statements (INSERT/UPDATE/DELETE/DDL) are refused, and a SELECT with no WHERE/JOIN/LIMIT is flagged as suspicious.
- **Ask in plain language:** `POST /dbapi/nl` turns a question such as *"all users registered longer than three days"* into a validated SELECT (auto-adding `deleted_at IS NULL` for soft-delete tables), using the platform AI gateway and re-prompting until the SQL validates; pass `execute=true` to also run it read-only.
- **Async:** `POST /dbapi/query/async` runs a heavy read query off the request path and streams progress over `WS /dbapi/query/{uid}/ws`.
- The Devii assistant exposes the same read-only capability to the **primary administrator only** (list, get, query, and natural-language SELECT); the database tools are added to the tool list only for that user, so every other administrator's Devii does not see them and is unaware the database API exists. It cannot change data through the database API.
### Pub/Sub bus (`/pubsub`)
A database-free publish/subscribe bus for live updates without polling. Clients connect to `WS /pubsub/ws` to subscribe to topics (with `foo.*` wildcards) and publish messages; backends and administrators can also publish over `POST /pubsub/publish`. Users may use their own `user.{uid}.*` namespace and subscribe to shared `public.*` topics; administrators and internal services may use any topic. The browser client is available as `app.pubsub.subscribe(topic, cb)` / `app.pubsub.publish(topic, data)`. The bus is in-memory and best-effort by design.
Two background services bridge persisted state onto the bus so the interface updates without per-client polling: the **Notification relay** pushes new in-app notifications as live toasts and refreshes each viewer's unread notification and message badges instantly, and the **Live view relay** pushes the admin live views (container list and instances, bot fleet, background services, AI usage, backups) to whichever administrators are watching, computing a snapshot only for views that currently have subscribers. Both run on the single service-lock owner and degrade to a low-frequency HTTP poll if the bus is unavailable.
## Testing
- **932 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
- **1959 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
- **A directory tree that mirrors the path.** api/e2e follow the endpoint path - each route segment is a directory and the last segment is the file, `{param}` segments dropped (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`, `GET /projects/{slug}/files/lines` -> `tests/api/projects/files/lines.py`). unit mirrors the source module path (`devplacepy/services/audit/store.py` -> `tests/unit/services/audit/store.py`). Run one tier with `make test-unit` / `make test-api` / `make test-e2e`
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
- Runs serially, one test at a time, in a single process (`make test`); the suite drives one uvicorn subprocess on port 10501 with its own temp database and `DEVPLACE_DATA_DIR`
@@ -751,11 +1102,13 @@ Open `http://<host>:${PORT}` (default 10500). `make docker-logs` tails output; `
```bash
git pull
make docker-up # restart with new code (bind-mounted, no rebuild)
make docker-reload # restart workers on the new code (bind-mounted, no rebuild)
make docker-build && \
make docker-up # only when dependencies in pyproject.toml change
```
`make docker-reload` is the target for a source-only change: `docker compose up -d` leaves an unchanged container running, so the workers would keep serving the code they imported at boot. A rebuild after a source-only change costs about 7 seconds because the Dockerfile installs dependencies from `pyproject.toml` in a layer that no source edit invalidates.
The `make deploy` target fast-forwards the `production` branch (`git checkout production && git merge master && git push origin production`); pull that branch on the server.
### Container Manager wiring (what the overlay does)
@@ -765,12 +1118,16 @@ The Container Manager drives the host Docker daemon, so `make docker-build`/`mak
What the overlay (`docker-compose.containers.yml`) changes:
- **Docker CLI in the image** via the `INSTALL_DOCKER_CLI=true` build arg (the base image stays lean).
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every build/run/exec is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every run/exec/lifecycle operation is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
- **Socket permissions:** the app runs as UID 1000, so the overlay adds the host `docker` group via `group_add`. `make` reads the gid straight from `/var/run/docker.sock` (`stat -c '%g'`), the exact group that owns the socket.
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
Then enable **Container builds** and **Containers** on `/admin/services`. Builds default to `--network=host` (configurable on the service) so pip can reach PyPI; set the build network to empty to use the docker default.
One piece of wiring cannot live in the overlay:
- **Workspace tunnel reach.** A workspace tunnel serves a port the member chose, which is almost never published on the host, so the app has to dial the container directly - and it can only do that from the container's own docker network. Compose cannot attach a service to the default `bridge` network (it always sends network-scoped aliases, which that network rejects), so `make docker-up` and `make docker-reload` run `make docker-attach`, an idempotent `docker network connect` of the app container to `DEVPLACE_CONTAINER_NETWORK` (default `bridge`). A bare `docker compose up -d` skips it and every tunnel to an unpublished port answers `502`. On a bare-metal `make prod` deploy the app is already on the host and reaches container IPs with no wiring at all.
Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.
### nginx specifics
@@ -779,6 +1136,16 @@ Then enable **Container builds** and **Containers** on `/admin/services`. Builds
- **Upload size:** `NGINX_MAX_BODY_SIZE` (default `50m`) must be **>= the admin-configurable `max_upload_size_mb`** (Admin -> Settings), or large uploads are rejected with HTTP 413 before reaching the app.
- **Micro-cache:** off by default; enable with `NGINX_CACHE_ENABLED=true`.
### Client IP behind a proxy
The app resolves the real client address via `utils.client_ip(request)`, which reads `X-Real-IP` first, then the leftmost hop of `X-Forwarded-For`, then falls back to `request.client.host`. This is the single source used by the rate limiter, the audit log (`actor_ip`), and guest-scoped job ownership, so every logged IP is the actual visitor rather than the proxy loopback. The bundled nginx config already sets both headers on every location. When fronting the app with **Caddy**, `X-Forwarded-For` is set automatically, but `X-Real-IP` is not; add an unspoofable real-IP header inside the `reverse_proxy` block for the strongest attribution:
```
reverse_proxy localhost:10500 {
header_up X-Real-IP {remote_host}
}
```
### Static asset caching
Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a boot-time version path segment, `/static/v<timestamp>/...`, where `<timestamp>` is the unix time the server process started (`config.STATIC_VERSION`). A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.
@@ -787,7 +1154,7 @@ The version sits in the **path**, not a query string, because the frontend is un
### Bare-metal alternative
`make prod` runs the same app without containers (`uvicorn ... --workers 2 --proxy-headers`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to `nproc`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
### Multi-worker safety
@@ -810,7 +1177,7 @@ Changes are promoted through automated DTAP streets: Development (`make dev`), T
2. Validate each touched language (Python compiles/imports, JS parses, CSS and HTML balance)
3. `make test` - run all tests (fail-fast)
4. Add tests in the matching tier and endpoint file (`tests/{unit,api,e2e}/<endpoint>.py`) for new functionality
5. Update `AGENTS.md` and `README.md` if new conventions were introduced
5. Update the relevant nested `CLAUDE.md` and `README.md` if new conventions were introduced
## License
+191 -13
View File
@@ -50,6 +50,52 @@ ALLOWED_UPLOAD_TYPES = {
".js": "text/javascript",
".css": "text/css",
".md": "text/markdown",
".wav": "audio/wav",
".flac": "audio/flac",
".ogg": "audio/ogg",
".aac": "audio/aac",
".wma": "audio/x-ms-wma",
".m4a": "audio/mp4",
".avi": "video/x-msvideo",
".mkv": "video/x-matroska",
".flv": "video/x-flv",
".wmv": "video/x-ms-wmv",
".3gp": "video/3gpp",
".csv": "text/csv",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".odt": "application/vnd.oasis.opendocument.text",
".rtf": "application/rtf",
".json": "application/json",
".xml": "application/xml",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".sh": "text/x-sh",
".bat": "text/x-bat",
".ts": "text/typescript",
".java": "text/x-java",
".cpp": "text/x-c++",
".c": "text/x-c",
".h": "text/x-c-header",
".rb": "text/x-ruby",
".go": "text/x-go",
".rs": "text/x-rust",
".sql": "text/x-sql",
".php": "text/x-php",
".swift": "text/x-swift",
".kt": "text/x-kotlin",
".cfg": "text/x-config",
".ini": "text/x-config",
".log": "text/plain",
".tar": "application/x-tar",
".gz": "application/gzip",
".rar": "application/vnd.rar",
".7z": "application/x-7z-compressed",
}
MIME_TO_EXT = {
@@ -69,6 +115,49 @@ MIME_TO_EXT = {
"audio/mpeg": ".mp3",
"text/plain": ".txt",
"text/markdown": ".md",
"audio/wav": ".wav",
"audio/flac": ".flac",
"audio/ogg": ".ogg",
"audio/aac": ".aac",
"audio/x-ms-wma": ".wma",
"audio/mp4": ".m4a",
"video/x-msvideo": ".avi",
"video/x-matroska": ".mkv",
"video/x-flv": ".flv",
"video/x-ms-wmv": ".wmv",
"video/3gpp": ".3gp",
"text/csv": ".csv",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.oasis.opendocument.text": ".odt",
"application/rtf": ".rtf",
"application/json": ".json",
"application/xml": ".xml",
"text/yaml": ".yaml",
"text/x-toml": ".toml",
"text/x-sh": ".sh",
"text/x-bat": ".bat",
"text/typescript": ".ts",
"text/x-java": ".java",
"text/x-c++": ".cpp",
"text/x-c": ".c",
"text/x-c-header": ".h",
"text/x-ruby": ".rb",
"text/x-go": ".go",
"text/x-rust": ".rs",
"text/x-sql": ".sql",
"text/x-php": ".php",
"text/x-swift": ".swift",
"text/x-kotlin": ".kt",
"text/x-config": ".cfg",
"application/x-tar": ".tar",
"application/gzip": ".gz",
"application/vnd.rar": ".rar",
"application/x-7z-compressed": ".7z",
}
FILE_ICONS = {
@@ -107,15 +196,17 @@ def _get_max_upload_bytes():
return int(get_setting("max_upload_size_mb", "10")) * 1024 * 1024
WILDCARD_TOKENS = {"*", ".*", "*.*"}
def allowed_extensions():
raw = get_setting("allowed_file_types", "").strip()
if raw:
return {
ext if ext.startswith(".") else f".{ext}"
for ext in (part.strip().lower() for part in raw.split(","))
if ext
}
return set(ALLOWED_UPLOAD_TYPES)
if not raw:
return set(ALLOWED_UPLOAD_TYPES)
tokens = {part.strip().lower() for part in raw.split(",") if part.strip()}
if tokens & WILDCARD_TOKENS:
return set(ALLOWED_UPLOAD_TYPES)
return {token if token.startswith(".") else f".{token}" for token in tokens}
def is_extension_allowed(ext):
@@ -205,6 +296,8 @@ def store_attachment(file_bytes, original_filename, user_uid):
if ext not in (".gif",):
thumbnail = _generate_thumbnail(file_bytes, file_dir / f"{uid}_thumb.jpg")
is_audio = mime.startswith("audio/")
get_table("attachments").insert(
{
"uid": uid,
@@ -220,6 +313,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
"image_height": image_height,
"has_thumbnail": 1 if thumbnail else 0,
"thumbnail_name": thumbnail,
"gitea_asset_id": None,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
@@ -237,6 +331,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
"has_thumbnail": thumbnail is not None,
"is_image": is_image,
"is_video": mime.startswith("video/"),
"is_audio": is_audio,
}
@@ -349,14 +444,78 @@ def link_attachments(uids, target_type, target_uid):
return
placeholders = ",".join(f":p{i}" for i in range(len(flat)))
params = {f"p{i}": uid for i, uid in enumerate(flat)}
db.query(
f"UPDATE attachments SET target_type=:tt, target_uid=:tu WHERE uid IN ({placeholders})",
tt=target_type,
tu=target_uid,
**params,
with db:
db.query(
f"UPDATE attachments SET target_type=:tt, target_uid=:tu WHERE uid IN ({placeholders})",
tt=target_type,
tu=target_uid,
**params,
)
def set_gitea_asset_id(uid, asset_id):
get_table("attachments").update(
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
)
async def mirror_attachment_to_gitea(uid):
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import GiteaError
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
return None
target_type = row.get("target_type", "")
target_uid = row.get("target_uid", "")
if target_type not in ("issue", "issue_comment") or not target_uid:
return None
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
try:
data = path.read_bytes()
except OSError as exc:
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
return None
filename = row.get("original_filename") or row.get("stored_name") or "file"
mime = row.get("mime_type") or "application/octet-stream"
client = runtime.get_client()
try:
if target_type == "issue":
asset = await client.create_issue_asset(
int(target_uid), filename, data, mime
)
else:
asset = await client.create_comment_asset(
int(target_uid), filename, data, mime
)
except (GiteaError, ValueError) as exc:
logger.warning("Gitea asset mirror failed for %s: %s", uid, exc)
return None
asset_id = int(asset.get("id", 0) or 0)
if asset_id:
set_gitea_asset_id(uid, asset_id)
return asset_id
async def remove_gitea_asset(row):
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import GiteaError
asset_id = int(row.get("gitea_asset_id") or 0)
target_type = row.get("target_type", "")
target_uid = row.get("target_uid", "")
if not asset_id or not target_uid:
return
client = runtime.get_client()
try:
if target_type == "issue":
await client.delete_issue_asset(int(target_uid), asset_id)
elif target_type == "issue_comment":
await client.delete_comment_asset(int(target_uid), asset_id)
except (GiteaError, ValueError) as exc:
logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc)
def _unlink_attachment_files(row):
stored_name = row.get("stored_name", "")
directory = row.get("directory", "")
@@ -387,6 +546,21 @@ def delete_attachment(uid):
_delete_attachment_row(row)
def rename_attachment(uid, filename):
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
return None
ext = Path(row.get("stored_name", "")).suffix.lower()
stem = Path(str(filename)).name.strip()
if ext:
stem = Path(stem).stem
if not stem:
return None
clean = f"{stem}{ext}"
get_table("attachments").update({"uid": uid, "original_filename": clean}, ["uid"])
return clean
def soft_delete_attachment(uid, deleted_by="system"):
row = get_table("attachments").find_one(uid=uid)
if not row or row.get("deleted_at"):
@@ -459,7 +633,8 @@ def delete_attachments_for(target_type, target_uids):
for row in rows:
_unlink_attachment_files(row)
ids = ",".join(str(row["id"]) for row in rows)
db.query(f"DELETE FROM attachments WHERE id IN ({ids})")
with db:
db.query(f"DELETE FROM attachments WHERE id IN ({ids})")
def get_attachments(target_type, target_uid):
@@ -521,8 +696,11 @@ def _row_to_attachment(row):
"has_thumbnail": bool(row.get("has_thumbnail")),
"is_image": row.get("mime_type", "").startswith("image/"),
"is_video": row.get("mime_type", "").startswith("video/"),
"is_audio": row.get("mime_type", "").startswith("audio/"),
"target_type": row.get("target_type", ""),
"target_uid": row.get("target_uid", ""),
"user_uid": row.get("user_uid", ""),
"gitea_asset_id": row.get("gitea_asset_id") or None,
"created_at": row.get("created_at", ""),
}
+7 -1
View File
@@ -9,6 +9,12 @@ def avatar_url(style: str, seed: str, size: int = 128) -> str:
return f"/avatar/{style}/{seed}?size={size}"
def avatar_seed(user) -> str:
if not user:
return ""
return user.get("avatar_seed") or user.get("username") or ""
def generate_avatar_svg(seed: str) -> str:
try:
from multiavatar.multiavatar import multiavatar
@@ -22,6 +28,6 @@ def generate_avatar_svg(seed: str) -> str:
initial = seed[:1].upper() if seed else "?"
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<rect width="100" height="100" rx="50" fill="#ff6b35"/>'
f'<rect width="100" height="100" rx="50" fill="#b73f1e"/>'
f'<text x="50" y="65" text-anchor="middle" fill="white" font-size="40" font-weight="700" font-family="sans-serif">{initial}</text></svg>'
)
View File
+33
View File
@@ -0,0 +1,33 @@
# retoor <retoor@molodetz.nl>
from io import BytesIO
from PIL import Image
def enforce_rgba_png(file_bytes: bytes) -> bytes:
img = Image.open(BytesIO(file_bytes)).convert("RGBA")
width, height = img.size
if width > 1 and height > 1:
corner = img.getpixel((0, 0))
if len(corner) == 4 and corner[3] == 255:
bg = corner[:3]
data = img.get_flattened_data()
cleaned = []
for pixel in data:
if pixel[:3] == bg:
cleaned.append((pixel[0], pixel[1], pixel[2], 0))
else:
cleaned.append(pixel)
img.putdata(cleaned)
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def resize_award_png(source: bytes, size: int) -> bytes:
img = Image.open(BytesIO(source)).convert("RGBA")
img = img.resize((size, size), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
+5 -5
View File
@@ -10,7 +10,7 @@ class TTLCache:
self.max_size = max_size
self._store = OrderedDict()
def get(self, key):
def get(self, key: str):
entry = self._store.get(key)
if entry is None:
return None
@@ -21,19 +21,19 @@ class TTLCache:
self._store.move_to_end(key)
return value
def set(self, key, value):
def set(self, key: str, value) -> None:
self._store[key] = (value, time.time() + self.ttl)
self._store.move_to_end(key)
if self.max_size and len(self._store) > self.max_size:
self._store.popitem(last=False)
def pop(self, key):
def pop(self, key: str) -> None:
self._store.pop(key, None)
def clear(self):
def clear(self) -> None:
self._store.clear()
def items(self):
def items(self) -> list:
now = time.time()
return [
(key, value) for key, (value, expiry) in self._store.items() if now < expiry
-817
View File
@@ -1,817 +0,0 @@
# retoor <retoor@molodetz.nl>
import argparse
import sys
from devplacepy.database import get_table
from devplacepy.utils import strip_html
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
from devplacepy.services.audit import record as audit
audit.record_system(
event_key,
actor_kind="cli",
actor_role="system",
origin="cli",
target_type=target_type,
target_uid=target_uid,
target_label=target_label,
summary=summary,
metadata=metadata,
links=links,
)
def cmd_role_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("role", "member").lower())
def cmd_role_set(args):
role = args.role.lower()
if role not in ("member", "admin"):
print("Role must be 'member' or 'admin'")
sys.exit(1)
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
old_role = user.get("role")
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.role.set",
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
metadata={"old": old_role, "new": role.capitalize()},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"User '{args.username}' role set to '{role}'")
def cmd_apikey_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("api_key", "") or "")
def cmd_apikey_reset(args):
from devplacepy.utils import generate_uid, clear_user_cache
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
new_key = generate_uid()
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
clear_user_cache(user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.apikey.reset",
f"CLI regenerated the API key of user {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(new_key)
def cmd_apikey_backfill(args):
from devplacepy.database import backfill_api_keys
updated = backfill_api_keys()
_audit_cli(
"cli.apikey.backfill",
f"CLI backfilled API keys for {updated} users",
metadata={"count": updated},
)
print(f"Assigned API keys to {updated} user(s) without one")
def cmd_devii_reset_quota(args):
from devplacepy.database import db
table_name = "devii_usage_ledger"
if table_name not in db.tables:
print(f"Table '{table_name}' does not exist, nothing to reset")
return
table = db[table_name]
if args.all:
count = table.count()
table.delete()
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
print(f"Reset all AI quotas ({count} ledger rows deleted)")
return
if args.guests:
count = table.count(owner_kind="guest")
table.delete(owner_kind="guest")
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
return
if not args.username:
print("Provide a username, or --guests, or --all")
sys.exit(1)
user = get_table("users").find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = table.count(owner_kind="user", owner_id=user["uid"])
table.delete(owner_kind="user", owner_id=user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.devii.quota.reset",
f"CLI reset AI quota for {args.username}",
metadata={"scope": "user", "rows_removed": count},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
def cmd_news_clear(args):
from devplacepy.database import db
deleted = {}
for table in ("news", "news_images", "news_sync"):
if table in db.tables:
count = db[table].count()
db[table].delete()
deleted[table] = count
print(f"Deleted {count} rows from '{table}'")
else:
print(f"Table '{table}' does not exist, skipping")
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
print("News data cleared")
def cmd_news_sanitize(args):
from devplacepy.database import db
if "news" not in db.tables:
print("News table does not exist")
return
news_table = db["news"]
updated = 0
for row in news_table.all():
desc = (strip_html(row.get("description", "") or ""))[:5000]
content = (strip_html(row.get("content", "") or ""))[:10000]
if desc != row.get("description", "") or content != row.get("content", ""):
news_table.update(
{"id": row["id"], "description": desc, "content": content}, ["id"]
)
updated += 1
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
print(f"Sanitized {updated} news article(s)")
def cmd_attachments_prune(args):
from datetime import datetime, timezone, timedelta
from devplacepy.database import db
from devplacepy.attachments import delete_attachment
if "attachments" not in db.tables:
print("Attachments table does not exist")
return
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
orphans = [
att
for att in db["attachments"].find(target_type="", target_uid="")
if att.get("created_at", "") < cutoff
]
for att in orphans:
delete_attachment(att["uid"])
_audit_cli(
"cli.attachments.prune",
f"CLI pruned {len(orphans)} orphan attachments",
metadata={"count": len(orphans), "hours": args.hours},
)
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
def _remove_zip_artifacts(job):
import shutil
from pathlib import Path
from devplacepy.services.jobs.zip_service import STAGING_DIR
local_path = (job.get("result") or {}).get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True)
def cmd_zips_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="zip", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
print(f"Pruned {removed} expired zip job(s)")
def cmd_zips_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="zip")
for job in jobs:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} zip job(s) and their archives")
def cmd_forks_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="fork", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
print(f"Pruned {removed} expired fork job(s)")
def cmd_forks_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="fork")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} fork job(s)")
def _remove_seo_artifacts(job):
import shutil
from devplacepy.config import SEO_REPORTS_DIR
shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)
def cmd_seo_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
print(f"Pruned {removed} expired SEO audit(s)")
def cmd_seo_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo")
for job in jobs:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} SEO audit(s) and their reports")
def _remove_deepsearch_artifacts(job):
import shutil
from devplacepy.config import DEEPSEARCH_DIR
from devplacepy.services.deepsearch.store import VectorStore
uid = job["uid"]
collection = f"ds_{uid.replace('-', '')}"
VectorStore(collection).drop()
shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True)
def cmd_deepsearch_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="deepsearch", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.deepsearch.prune",
f"CLI pruned {removed} expired DeepSearch jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired DeepSearch job(s)")
def cmd_deepsearch_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="deepsearch")
for job in jobs:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.deepsearch.clear",
f"CLI cleared all DeepSearch jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
def cmd_containers_list(args):
from devplacepy.services.containers import store
instances = store.all_instances()
if not instances:
print("No container instances")
return
for inst in instances:
print(
f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
)
def cmd_containers_reconcile(args):
import asyncio
from devplacepy.services.containers.service import ContainerService
asyncio.run(ContainerService().run_once())
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
print("Reconcile pass complete")
def cmd_containers_prune(args):
import asyncio
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.containers.service import ContainerService
async def run():
await ContainerService().run_once()
await get_backend().image_prune()
asyncio.run(run())
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
print("Reaped orphans and pruned dangling images")
def cmd_containers_prune_builds(args):
import asyncio
from devplacepy.database import db, get_table
from devplacepy.services.containers.runtime import get_backend
async def run():
backend = get_backend()
removed = 0
if "builds" in db.tables:
for build in list(get_table("builds").find()):
tag = build.get("image_tag")
if tag:
await backend.remove_image(tag)
removed += 1
for table in ("builds", "dockerfile_versions", "dockerfiles"):
if table in db.tables:
get_table(table).delete()
return removed
removed = asyncio.run(run())
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
print(
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
)
def cmd_containers_gc_workspaces(args):
import shutil
from pathlib import Path
from devplacepy import config
from devplacepy.services.containers import store
active = {inst["project_uid"] for inst in store.all_instances()}
base = Path(config.CONTAINER_WORKSPACES_DIR)
removed = 0
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and child.name not in active:
shutil.rmtree(child, ignore_errors=True)
removed += 1
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
print(
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
)
def _crc32(path):
import zlib
crc = 0
with open(path, "rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
crc = zlib.crc32(chunk, crc)
return crc & 0xFFFFFFFF
def _migrate_file(source, dest, dry_run, report):
import os
import shutil
if not source.exists():
return
if source.resolve() == dest.resolve():
return
size = source.stat().st_size
if dest.exists():
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
report.append(("done", source, dest, size))
if not dry_run:
source.unlink()
return
report.append(("conflict", source, dest, size))
return
report.append(("move", source, dest, size))
if dry_run:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".migrating")
shutil.copyfile(source, tmp)
with open(tmp, "rb") as handle:
os.fsync(handle.fileno())
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
tmp.unlink(missing_ok=True)
raise RuntimeError(f"verification failed copying {source} -> {dest}")
os.replace(tmp, dest)
source.unlink()
def _prune_empty_dirs(root):
if not root.exists():
return
for path in sorted(root.rglob("*"), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
try:
root.rmdir()
except OSError:
pass
def _migrate_tree(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
for child in sorted(source.rglob("*")):
if child.is_file():
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
if not dry_run:
_prune_empty_dirs(source)
def _db_is_locked(path):
import sqlite3
try:
conn = sqlite3.connect(str(path), timeout=0.5)
try:
conn.execute("BEGIN IMMEDIATE")
conn.rollback()
return False
finally:
conn.close()
except sqlite3.OperationalError:
return True
def _checkpoint(path):
import sqlite3
conn = sqlite3.connect(str(path), timeout=5)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.commit()
finally:
conn.close()
def _migrate_db(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
if _db_is_locked(source):
raise RuntimeError(
f"{source} is locked - stop the app before running migrate-data"
)
if not dry_run:
_checkpoint(source)
_migrate_file(source, dest, dry_run, report)
for suffix in ("-wal", "-shm"):
_migrate_file(
source.with_name(source.name + suffix),
dest.with_name(dest.name + suffix),
dry_run,
report,
)
def cmd_migrate_data(args):
import os
from pathlib import Path
from collections import Counter
from devplacepy import config
base = config.BASE_DIR
home = Path.home()
dry = args.dry_run
report = []
config.ensure_data_dirs()
db_items = []
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
else:
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
if not os.environ.get("DEVII_TASKS_DB"):
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
if not os.environ.get("DEVII_LESSONS_DB"):
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
file_items = [
(base / name, config.KEYS_DIR / name)
for name in (
"notification-private.pem",
"notification-private.pkcs8.pem",
"notification-public.pem",
)
]
registry_dest = config.BOT_DIR / "article_registry.json"
registry_sources = [
path
for path in (
home / ".dpbot_article_registry.json",
base / ".dpbot_article_registry.json",
)
if path.exists()
]
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
if registry_sources:
file_items.append((registry_sources[0], registry_dest))
for stale in registry_sources[1:]:
print(f"Leaving older duplicate registry untouched: {stale}")
legacy_var = base / "var"
tree_items = [
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
(home / ".devplace_bots", config.BOT_DIR),
]
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
try:
for source, dest in db_items:
_migrate_db(source, dest, dry, report)
for source, dest in file_items:
_migrate_file(source, dest, dry, report)
for source, dest in tree_items:
_migrate_tree(source, dest, dry, report)
except RuntimeError as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if not report:
print("Nothing to migrate; the data directory is already consolidated.")
return
for status, source, dest, size in report:
print(f" [{status}] {source} -> {dest} ({size} bytes)")
counts = Counter(status for status, *_ in report)
print()
print(
("Planned: " if dry else "Migrated: ")
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
)
if any(status == "conflict" for status, *_ in report):
print(
"Conflicts left both source and destination untouched; resolve them by hand."
)
if dry:
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
def main():
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
sub = parser.add_subparsers(title="commands", dest="command")
role = sub.add_parser("role", help="Manage user roles")
role_sub = role.add_subparsers(title="action", dest="action")
role_get = role_sub.add_parser("get", help="Get a user's role")
role_get.add_argument("username")
role_get.set_defaults(func=cmd_role_get)
role_set = role_sub.add_parser("set", help="Set a user's role")
role_set.add_argument("username")
role_set.add_argument("role", choices=["member", "admin"])
role_set.set_defaults(func=cmd_role_set)
apikey = sub.add_parser("apikey", help="Manage user API keys")
apikey_sub = apikey.add_subparsers(title="action", dest="action")
apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
apikey_get.add_argument("username")
apikey_get.set_defaults(func=cmd_apikey_get)
apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
apikey_reset.add_argument("username")
apikey_reset.set_defaults(func=cmd_apikey_reset)
apikey_backfill = apikey_sub.add_parser(
"backfill", help="Assign API keys to users that lack one"
)
apikey_backfill.set_defaults(func=cmd_apikey_backfill)
news = sub.add_parser("news", help="News management")
news_sub = news.add_subparsers(title="action", dest="action")
news_clear = news_sub.add_parser(
"clear", help="Delete all news from local database"
)
news_clear.set_defaults(func=cmd_news_clear)
news_sanitize = news_sub.add_parser(
"sanitize", help="Strip HTML from all existing news descriptions and content"
)
news_sanitize.set_defaults(func=cmd_news_sanitize)
attachments = sub.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action")
att_prune = att_sub.add_parser(
"prune", help="Remove orphaned attachment records and files"
)
att_prune.add_argument(
"--hours",
type=int,
default=24,
help="Only prune orphans older than this many hours",
)
att_prune.set_defaults(func=cmd_attachments_prune)
devii = sub.add_parser("devii", help="Devii assistant management")
devii_sub = devii.add_subparsers(title="action", dest="action")
devii_reset = devii_sub.add_parser(
"reset-quota", help="Reset the rolling 24h AI spend quota"
)
devii_reset.add_argument(
"username", nargs="?", help="Reset the quota for a single user"
)
devii_reset.add_argument(
"--guests", action="store_true", help="Reset every guest quota"
)
devii_reset.add_argument(
"--all", action="store_true", help="Reset every quota (users and guests)"
)
devii_reset.set_defaults(func=cmd_devii_reset_quota)
zips = sub.add_parser("zips", help="Zip archive job management")
zips_sub = zips.add_subparsers(title="action", dest="action")
zips_prune = zips_sub.add_parser(
"prune", help="Delete expired zip archives and their job rows"
)
zips_prune.set_defaults(func=cmd_zips_prune)
zips_clear = zips_sub.add_parser(
"clear", help="Delete every zip archive and job row"
)
zips_clear.set_defaults(func=cmd_zips_clear)
forks = sub.add_parser("forks", help="Fork job management")
forks_sub = forks.add_subparsers(title="action", dest="action")
forks_prune = forks_sub.add_parser(
"prune", help="Delete expired completed fork job rows (forked projects persist)"
)
forks_prune.set_defaults(func=cmd_forks_prune)
forks_clear = forks_sub.add_parser(
"clear", help="Delete every fork job row (forked projects persist)"
)
forks_clear.set_defaults(func=cmd_forks_clear)
seo = sub.add_parser("seo", help="SEO Diagnostics job management")
seo_sub = seo.add_subparsers(title="action", dest="action")
seo_prune = seo_sub.add_parser(
"prune", help="Delete expired SEO audit reports and their job rows"
)
seo_prune.set_defaults(func=cmd_seo_prune)
seo_clear = seo_sub.add_parser(
"clear", help="Delete every SEO audit report and job row"
)
seo_clear.set_defaults(func=cmd_seo_clear)
deepsearch = sub.add_parser("deepsearch", help="DeepSearch job management")
deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action")
deepsearch_prune = deepsearch_sub.add_parser(
"prune", help="Delete expired DeepSearch sessions and their job rows"
)
deepsearch_prune.set_defaults(func=cmd_deepsearch_prune)
deepsearch_clear = deepsearch_sub.add_parser(
"clear", help="Delete every DeepSearch session and job row"
)
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)
containers = sub.add_parser("containers", help="Container manager")
containers_sub = containers.add_subparsers(title="action", dest="action")
containers_sub.add_parser("list", help="List container instances").set_defaults(
func=cmd_containers_list
)
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
func=cmd_containers_reconcile
)
containers_sub.add_parser(
"prune", help="Reap orphan containers and dangling images"
).set_defaults(func=cmd_containers_prune)
containers_sub.add_parser(
"prune-builds",
help="Remove legacy per-project images and clear the dockerfiles/builds tables",
).set_defaults(func=cmd_containers_prune_builds)
containers_sub.add_parser(
"gc-workspaces", help="Remove workspace dirs with no instances"
).set_defaults(func=cmd_containers_gc_workspaces)
migrate = sub.add_parser(
"migrate-data",
help="Relocate legacy runtime files into the consolidated data/ directory",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Print the source-to-destination plan without changing anything",
)
migrate.set_defaults(func=cmd_migrate_data)
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli.main import main, build_parser
from devplacepy.cli._shared import _audit_cli
from devplacepy.cli.accounts import cmd_accounts_pending, cmd_accounts_prune
from devplacepy.cli.roles import cmd_role_get, cmd_role_set
from devplacepy.cli.apikeys import cmd_apikey_get, cmd_apikey_reset, cmd_apikey_backfill
from devplacepy.cli.tokens import (
cmd_token_issue,
cmd_token_list,
cmd_token_revoke,
cmd_token_revoke_all,
cmd_token_prune,
)
from devplacepy.cli.devii import cmd_devii_reset_quota
from devplacepy.cli.news import cmd_news_clear, cmd_news_sanitize
from devplacepy.cli.attachments import cmd_attachments_prune
from devplacepy.cli.jobs import (
cmd_zips_prune,
cmd_zips_clear,
cmd_forks_prune,
cmd_forks_clear,
cmd_seo_prune,
cmd_seo_clear,
cmd_isslop_prune,
cmd_isslop_clear,
cmd_isslop_analyze,
cmd_seo_meta_prune,
cmd_seo_meta_clear,
cmd_deepsearch_prune,
cmd_deepsearch_clear,
)
from devplacepy.cli.backups import (
cmd_backups_list,
cmd_backups_run,
cmd_backups_prune,
cmd_backups_clear,
)
from devplacepy.cli.containers import (
cmd_containers_list,
cmd_containers_reconcile,
cmd_containers_prune,
cmd_containers_prune_builds,
cmd_containers_gc_workspaces,
)
from devplacepy.cli.quiz import cmd_quiz_prune
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
__all__ = [
"main",
"build_parser",
"_audit_cli",
"cmd_accounts_pending",
"cmd_accounts_prune",
"cmd_role_get",
"cmd_role_set",
"cmd_apikey_get",
"cmd_apikey_reset",
"cmd_apikey_backfill",
"cmd_token_issue",
"cmd_token_list",
"cmd_token_revoke",
"cmd_token_revoke_all",
"cmd_token_prune",
"cmd_devii_reset_quota",
"cmd_news_clear",
"cmd_news_sanitize",
"cmd_attachments_prune",
"cmd_zips_prune",
"cmd_zips_clear",
"cmd_forks_prune",
"cmd_forks_clear",
"cmd_seo_prune",
"cmd_seo_clear",
"cmd_isslop_prune",
"cmd_isslop_clear",
"cmd_isslop_analyze",
"cmd_seo_meta_prune",
"cmd_seo_meta_clear",
"cmd_deepsearch_prune",
"cmd_deepsearch_clear",
"cmd_backups_list",
"cmd_backups_run",
"cmd_backups_prune",
"cmd_backups_clear",
"cmd_containers_list",
"cmd_containers_reconcile",
"cmd_containers_prune",
"cmd_containers_prune_builds",
"cmd_containers_gc_workspaces",
"cmd_quiz_prune",
"cmd_emoji_sync",
"cmd_migrate_data",
]
+6
View File
@@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli.main import main
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
# retoor <retoor@molodetz.nl>
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
from devplacepy.services.audit import record as audit
audit.record_system(
event_key,
actor_kind="cli",
actor_role="system",
origin="cli",
target_type=target_type,
target_uid=target_uid,
target_label=target_label,
summary=summary,
metadata=metadata,
links=links,
)
+46
View File
@@ -0,0 +1,46 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_accounts_prune(args):
from devplacepy.services.moderation import deletion
pending = deletion.due_purges()
if args.dry_run:
for row in pending:
print(f"{row['uid']} deleted at {row['deletion_requested_at']}")
print(f"{len(pending)} account(s) due for purge")
return
purged = deletion.purge_due()
_audit_cli(
"cli.accounts.prune",
f"CLI purged {purged} deleted account(s) past the grace window",
metadata={"count": purged},
)
print(f"Purged {purged} deleted account(s)")
def cmd_accounts_pending(args):
from devplacepy.services.moderation import deletion
pending = deletion.due_purges()
for row in pending:
print(f"{row['uid']}\t{row['deletion_requested_at']}")
print(f"{len(pending)} account(s) past the {deletion.grace_hours()}h grace window")
def register_accounts(subparsers):
accounts = subparsers.add_parser("accounts", help="Deleted account management")
accounts_sub = accounts.add_subparsers(title="action", dest="action")
prune = accounts_sub.add_parser(
"prune", help="Permanently purge accounts past the deletion grace window"
)
prune.add_argument(
"--dry-run", action="store_true", help="List what would be purged and exit"
)
prune.set_defaults(func=cmd_accounts_prune)
pending = accounts_sub.add_parser(
"pending", help="List deleted accounts awaiting their purge"
)
pending.set_defaults(func=cmd_accounts_pending)
+65
View File
@@ -0,0 +1,65 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def cmd_apikey_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("api_key", "") or "")
def cmd_apikey_reset(args):
from devplacepy.utils import generate_uid, clear_user_cache
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
new_key = generate_uid()
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
clear_user_cache(user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.apikey.reset",
f"CLI regenerated the API key of user {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(new_key)
def cmd_apikey_backfill(args):
from devplacepy.database import backfill_api_keys
updated = backfill_api_keys()
_audit_cli(
"cli.apikey.backfill",
f"CLI backfilled API keys for {updated} users",
metadata={"count": updated},
)
print(f"Assigned API keys to {updated} user(s) without one")
def register_apikeys(subparsers):
apikey = subparsers.add_parser("apikey", help="Manage user API keys")
apikey_sub = apikey.add_subparsers(title="action", dest="action")
apikey_get = apikey_sub.add_parser("get", help="Print a user's API key")
apikey_get.add_argument("username")
apikey_get.set_defaults(func=cmd_apikey_get)
apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key")
apikey_reset.add_argument("username")
apikey_reset.set_defaults(func=cmd_apikey_reset)
apikey_backfill = apikey_sub.add_parser(
"backfill", help="Assign API keys to users that lack one"
)
apikey_backfill.set_defaults(func=cmd_apikey_backfill)
+43
View File
@@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_attachments_prune(args):
from datetime import datetime, timezone, timedelta
from devplacepy.database import db
from devplacepy.attachments import delete_attachment
if "attachments" not in db.tables:
print("Attachments table does not exist")
return
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
orphans = [
att
for att in db["attachments"].find(target_type="", target_uid="")
if att.get("created_at", "") < cutoff
]
for att in orphans:
delete_attachment(att["uid"])
_audit_cli(
"cli.attachments.prune",
f"CLI pruned {len(orphans)} orphan attachments",
metadata={"count": len(orphans), "hours": args.hours},
)
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
def register_attachments(subparsers):
attachments = subparsers.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action")
att_prune = att_sub.add_parser(
"prune", help="Remove orphaned attachment records and files"
)
att_prune.add_argument(
"--hours",
type=int,
default=24,
help="Only prune orphans older than this many hours",
)
att_prune.set_defaults(func=cmd_attachments_prune)
+82
View File
@@ -0,0 +1,82 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_backups_list(args):
from devplacepy.services.backup import store
backups = store.list_backups()
if not backups:
print("No backups recorded")
return
for backup in backups:
size = store.human_bytes(int(backup.get("size_bytes") or 0))
print(
f"{backup['uid']} {backup.get('target', ''):<9} "
f"{backup.get('status', ''):<8} {size:>10} "
f"{backup.get('created_at', '')} {backup.get('filename', '')}"
)
def cmd_backups_run(args):
from devplacepy.services.backup import store
from devplacepy.services.jobs import queue
if not store.is_valid_target(args.target):
print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}")
sys.exit(1)
job_uid = queue.enqueue(
"backup",
{"target": args.target, "schedule_uid": "", "created_by": "cli"},
owner_kind="system",
owner_id="cli",
preferred_name=f"{store.target_label(args.target)} (cli)",
)
store.create_backup(target=args.target, created_by="cli", job_uid=job_uid)
_audit_cli(
"cli.backups.run",
f"CLI enqueued {args.target} backup",
metadata={"target": args.target, "job_uid": job_uid},
)
print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)")
def cmd_backups_prune(args):
from devplacepy.services.backup import store
removed = store.prune_orphans()
_audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed})
print(f"Pruned {removed} orphan backup record(s)")
def cmd_backups_clear(args):
from devplacepy.services.backup import store
removed = store.clear_all()
_audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed})
print(f"Cleared {removed} backup(s) and their archives")
def register_backups(subparsers):
backups = subparsers.add_parser("backups", help="Backup management")
backups_sub = backups.add_subparsers(title="action", dest="action")
backups_sub.add_parser("list", help="List recorded backups").set_defaults(
func=cmd_backups_list
)
backups_run = backups_sub.add_parser(
"run", help="Enqueue a backup (processed by the running server)"
)
backups_run.add_argument(
"target",
choices=["database", "uploads", "keys", "full"],
help="What to back up",
)
backups_run.set_defaults(func=cmd_backups_run)
backups_sub.add_parser(
"prune", help="Remove backup records whose archive file is missing"
).set_defaults(func=cmd_backups_prune)
backups_sub.add_parser(
"clear", help="Delete every backup archive and record"
).set_defaults(func=cmd_backups_clear)
+107
View File
@@ -0,0 +1,107 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_containers_list(args):
from devplacepy.services.containers import store
instances = store.all_instances()
if not instances:
print("No container instances")
return
for inst in instances:
print(
f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} "
f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}"
)
def cmd_containers_reconcile(args):
import asyncio
from devplacepy.services.containers.service import ContainerService
asyncio.run(ContainerService().run_once())
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
print("Reconcile pass complete")
def cmd_containers_prune(args):
import asyncio
from devplacepy.services.containers.runtime import get_backend
from devplacepy.services.containers.service import ContainerService
async def run():
await ContainerService().run_once()
await get_backend().image_prune()
asyncio.run(run())
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
print("Reaped orphans and pruned dangling images")
def cmd_containers_prune_builds(args):
import asyncio
from devplacepy.database import db, get_table
from devplacepy.services.containers.runtime import get_backend
async def run():
backend = get_backend()
removed = 0
if "builds" in db.tables:
for build in list(get_table("builds").find()):
tag = build.get("image_tag")
if tag:
await backend.remove_image(tag)
removed += 1
for table in ("builds", "dockerfile_versions", "dockerfiles"):
if table in db.tables:
get_table(table).delete()
return removed
removed = asyncio.run(run())
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
print(
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
)
def cmd_containers_gc_workspaces(args):
import shutil
from pathlib import Path
from devplacepy import config
from devplacepy.services.containers import store
active = {inst["project_uid"] for inst in store.all_instances()}
base = Path(config.CONTAINER_WORKSPACES_DIR)
removed = 0
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and child.name not in active:
shutil.rmtree(child, ignore_errors=True)
removed += 1
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
print(
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
)
def register_containers(subparsers):
containers = subparsers.add_parser("containers", help="Container manager")
containers_sub = containers.add_subparsers(title="action", dest="action")
containers_sub.add_parser("list", help="List container instances").set_defaults(
func=cmd_containers_list
)
containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults(
func=cmd_containers_reconcile
)
containers_sub.add_parser(
"prune", help="Reap orphan containers and dangling images"
).set_defaults(func=cmd_containers_prune)
containers_sub.add_parser(
"prune-builds",
help="Remove legacy per-project images and clear the dockerfiles/builds tables",
).set_defaults(func=cmd_containers_prune_builds)
containers_sub.add_parser(
"gc-workspaces", help="Remove workspace dirs with no instances"
).set_defaults(func=cmd_containers_gc_workspaces)
+256
View File
@@ -0,0 +1,256 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import db, get_table
from devplacepy.cli._shared import _audit_cli
def cmd_devii_reset_quota(args):
table_name = "devii_usage_ledger"
if table_name not in db.tables:
print(f"Table '{table_name}' does not exist, nothing to reset")
return
table = db[table_name]
if args.all:
count = table.count()
table.delete()
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
print(f"Reset all AI quotas ({count} ledger rows deleted)")
return
if args.guests:
count = table.count(owner_kind="guest")
table.delete(owner_kind="guest")
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
return
if not args.username:
print("Provide a username, or --guests, or --all")
sys.exit(1)
user = get_table("users").find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = table.count(owner_kind="user", owner_id=user["uid"])
table.delete(owner_kind="user", owner_id=user["uid"])
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.devii.quota.reset",
f"CLI reset AI quota for {args.username}",
metadata={"scope": "user", "rows_removed": count},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
def _active_count() -> int:
if "devii_lessons" not in db.tables:
return 0
return db["devii_lessons"].count(deleted_at=None)
def _soft_deleted_count() -> int:
if "devii_lessons" not in db.tables:
return 0
return db["devii_lessons"].count(deleted_at={"!=": None})
def cmd_devii_lessons_count(args):
active = _active_count()
deleted = _soft_deleted_count()
print(f"Lessons: {active} active, {deleted} soft-deleted ({active + deleted} total)")
def cmd_devii_lessons_clear(args):
from devplacepy.services.devii.agentic.lessons import TABLE
if TABLE not in db.tables:
print("No devii_lessons table exists")
return
active = _active_count()
deleted = _soft_deleted_count()
total = active + deleted
if not args.force:
print(f"Will delete {total} lesson(s) ({active} active, {deleted} soft-deleted). Pass --force to confirm.")
return
db[TABLE].delete()
_audit_cli("cli.devii.lessons.clear", "CLI cleared all devii_lessons", metadata={"active": active, "soft_deleted": deleted})
print(f"Deleted {total} lesson(s)")
def cmd_devii_lessons_prune(args):
from devplacepy.services.devii.agentic.lessons import LessonStore, _read_retention_settings
if "devii_lessons" not in db.tables:
print("No devii_lessons table exists")
return
active_before = _active_count()
if args.all_owners:
_, max_age = _read_retention_settings(db)
store = LessonStore(db, "_global", "_global")
pruned = store.prune_all_owners(max_age)
elif args.username:
user = get_table("users").find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
_, max_age = _read_retention_settings(db)
store = LessonStore(db, "user", user["uid"])
pruned = store.prune(max_age)
else:
print("Provide --all-owners, or --username USER")
sys.exit(1)
_audit_cli("cli.devii.lessons.prune", "CLI pruned devii_lessons", metadata={"pruned": pruned, "active_before": active_before})
print(f"Pruned {pruned} lesson(s) (active before: {active_before}, now: {_active_count()})")
def _task_rows(enabled_only: bool) -> list:
from devplacepy.services.devii.tasks.store import TABLE
if TABLE not in db.tables:
return []
criteria = {"deleted_at": None}
if enabled_only:
criteria["enabled"] = True
rows = list(db[TABLE].find(**criteria))
rows.sort(key=lambda row: int(row.get("run_count") or 0), reverse=True)
return rows
def _owner_name(owner_id: str) -> str:
user = get_table("users").find_one(uid=owner_id)
return user["username"] if user else owner_id
def cmd_devii_tasks_list(args):
rows = _task_rows(not args.all)
if not rows:
print("No tasks")
return
for row in rows:
schedule = (
f"every {row.get('every_seconds')}s"
if row.get("kind") == "interval"
else (row.get("cron") or row.get("run_at") or "")
)
print(
f"{row.get('uid')} {_owner_name(str(row.get('owner_id') or '')):16} "
f"{'on ' if row.get('enabled') else 'off'} {str(row.get('status')):9} "
f"runs={row.get('run_count')}/{row.get('max_runs') or '-'} "
f"{schedule:24} {row.get('label') or ''}"
)
def cmd_devii_tasks_disable(args):
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
if TABLE not in db.tables:
print("No devii_tasks table exists")
return
row = db[TABLE].find_one(uid=args.uid, deleted_at=None)
if not row:
print(f"Task '{args.uid}' not found")
sys.exit(1)
store = TaskStore(db, str(row.get("owner_kind") or "user"), str(row.get("owner_id") or ""))
store.update(
args.uid,
{
"enabled": False,
"status": "disabled",
"next_run_at": None,
"last_error": "disabled from the command line",
},
)
_audit_cli(
"cli.devii.task.disable",
f"CLI disabled Devii task {args.uid}",
metadata={"owner_id": row.get("owner_id"), "run_count": row.get("run_count")},
target_type="task",
target_uid=args.uid,
target_label=row.get("label"),
)
print(f"Disabled task '{args.uid}'")
def cmd_devii_tasks_prune(args):
from devplacepy.services.devii.tasks.guards import automation_allowed
from devplacepy.services.devii.tasks.store import TABLE, TaskStore
if TABLE not in db.tables:
print("No devii_tasks table exists")
return
pruned = 0
for row in list(db[TABLE].find(enabled=True, deleted_at=None)):
owner_kind = str(row.get("owner_kind") or "")
owner_id = str(row.get("owner_id") or "")
if automation_allowed(owner_kind, owner_id):
continue
store = TaskStore(db, owner_kind, owner_id)
store.update(
row["uid"],
{
"enabled": False,
"status": "disabled",
"next_run_at": None,
"last_error": "owner is not an administrator",
},
)
pruned += 1
_audit_cli(
"cli.devii.task.prune",
"CLI disabled tasks whose owner may not schedule",
metadata={"disabled": pruned},
)
print(f"Disabled {pruned} task(s) whose owner is not an administrator")
def register_devii(subparsers):
devii = subparsers.add_parser("devii", help="Devii assistant management")
devii_sub = devii.add_subparsers(title="action", dest="action")
devii_reset = devii_sub.add_parser(
"reset-quota", help="Reset the rolling 24h AI spend quota"
)
devii_reset.add_argument(
"username", nargs="?", help="Reset the quota for a single user"
)
devii_reset.add_argument(
"--guests", action="store_true", help="Reset every guest quota"
)
devii_reset.add_argument(
"--all", action="store_true", help="Reset every quota (users and guests)"
)
devii_reset.set_defaults(func=cmd_devii_reset_quota)
devii_lessons = devii_sub.add_parser("lessons", help="Manage persisted Devii lesson data")
lessons_sub = devii_lessons.add_subparsers(title="sub-action", dest="sub_action")
lessons_count = lessons_sub.add_parser("count", help="Count active and soft-deleted lessons")
lessons_count.set_defaults(func=cmd_devii_lessons_count)
lessons_prune = lessons_sub.add_parser("prune", help="Soft-delete lessons older than the configured max age")
lessons_prune.add_argument("--all-owners", action="store_true", help="Prune across every owner")
lessons_prune.add_argument("--username", help="Prune for a specific user")
lessons_prune.set_defaults(func=cmd_devii_lessons_prune)
lessons_clear = lessons_sub.add_parser("clear", help="Hard-delete every devii_lessons row")
lessons_clear.add_argument("--force", action="store_true", help="Required to confirm hard deletion")
lessons_clear.set_defaults(func=cmd_devii_lessons_clear)
devii_tasks = devii_sub.add_parser("tasks", help="Inspect and stop scheduled Devii tasks")
tasks_sub = devii_tasks.add_subparsers(title="sub-action", dest="sub_action")
tasks_list = tasks_sub.add_parser("list", help="List scheduled tasks and their owners")
tasks_list.add_argument("--all", action="store_true", help="Include disabled tasks")
tasks_list.set_defaults(func=cmd_devii_tasks_list)
tasks_disable = tasks_sub.add_parser("disable", help="Disable one task by uid")
tasks_disable.add_argument("uid", help="Uid of the task")
tasks_disable.set_defaults(func=cmd_devii_tasks_disable)
tasks_prune = tasks_sub.add_parser(
"prune", help="Disable every task whose owner is not an administrator"
)
tasks_prune.set_defaults(func=cmd_devii_tasks_prune)
+103
View File
@@ -0,0 +1,103 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_game_market_prune(args):
from devplacepy.services.game import store
removed = store.prune_ticks()
_audit_cli(
"cli.game.market.prune",
f"CLI pruned {removed} stale Code Farm market tick(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} stale market tick bucket(s)")
def cmd_game_steals_prune(args):
from devplacepy.services.game import store
removed = store.prune_steals()
_audit_cli(
"cli.game.steals.prune",
f"CLI pruned {removed} old Code Farm raid record(s)",
metadata={"count": removed},
)
print(f"Pruned {removed} raid record(s)")
def cmd_game_era_status(args):
from devplacepy.services.game import store
era = store.active_era()
if not era:
print("No Era is currently running.")
return
print(f"Era {era['era_number']}: {era['name']}")
print(f"Started: {era['started_at']}")
print(f"Scheduled end: {era['ends_at']}")
def cmd_game_era_start(args):
from devplacepy.services.game import GameError, store
try:
era = store.start_era(args.name, args.duration_days)
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.start",
f"CLI started Code Farm Era {era['era_number']}: {era['name']}",
metadata={"era_number": era["era_number"], "name": era["name"]},
)
print(f"Started Era {era['era_number']}: {era['name']}")
def cmd_game_era_end(args):
from devplacepy.services.game import GameError, store
try:
result = store.end_era()
except GameError as exc:
print(f"Error: {exc}")
return
_audit_cli(
"cli.game.era.end",
f"CLI ended Code Farm Era {result['era_number']}",
metadata=result,
)
print(f"Ended Era {result['era_number']} ({result['participants']} participant(s) ranked)")
def register_game(subparsers):
game = subparsers.add_parser("game", help="Code Farm management")
game_sub = game.add_subparsers(title="action", dest="action")
market = game_sub.add_parser("market", help="Code Farm market saturation data")
market_sub = market.add_subparsers(title="market_action", dest="market_action")
market_prune = market_sub.add_parser(
"prune", help="Delete market tick buckets older than the tracking window"
)
market_prune.set_defaults(func=cmd_game_market_prune)
steals = game_sub.add_parser("steals", help="Code Farm raid history")
steals_sub = steals.add_subparsers(title="steals_action", dest="steals_action")
steals_prune = steals_sub.add_parser(
"prune", help="Delete raid records older than the raid-efficiency window"
)
steals_prune.set_defaults(func=cmd_game_steals_prune)
era = game_sub.add_parser("era", help="Code Farm Era management")
era_sub = era.add_subparsers(title="era_action", dest="era_action")
era_status = era_sub.add_parser("status", help="Show the current Era status")
era_status.set_defaults(func=cmd_game_era_status)
era_start = era_sub.add_parser("start", help="Start a new Era")
era_start.add_argument("name", help="Era name")
era_start.add_argument(
"--days", dest="duration_days", type=int, default=28, help="Planned Era length in days"
)
era_start.set_defaults(func=cmd_game_era_start)
era_end = era_sub.add_parser("end", help="End the currently running Era")
era_end.set_defaults(func=cmd_game_era_end)
+145
View File
@@ -0,0 +1,145 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def cmd_gateway_quota_list(args):
from devplacepy.services.openai_gateway import quota
rules = quota.quota_rule_store.list()
if not rules:
print("No quota rules. Every caller is capped by the global defaults on /admin/services/openai.")
return
for rule in rules:
spent = quota.spent_24h(rule["owner_kind"], rule["owner_id"], rule["app_reference"])
scope = ", ".join(
f"{key}={rule[key]}" for key in ("owner_kind", "owner_id", "app_reference") if rule[key]
) or "(no dimensions - invalid)"
limit = "unlimited" if rule["limit_usd"] == 0 else f"${rule['limit_usd']:.2f}/24h"
active = "active" if rule["is_active"] else "inactive"
label = f" - {rule['label']}" if rule["label"] else ""
print(f"{rule['uid']} [{scope}] {limit} spent=${spent:.4f} {active}{label}")
def cmd_gateway_quota_set(args):
from pydantic import ValidationError
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaRuleIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
limit_usd=args.limit_usd,
is_active=not args.inactive,
label=args.label or "",
)
except ValidationError as exc:
print(f"Invalid rule: {exc.errors()[0].get('msg', exc)}")
sys.exit(1)
saved = quota.quota_rule_store.set(payload, uid=args.uid, created_by="cli")
_audit_cli(
"gateway.quota_rule.update",
f"CLI saved gateway quota rule {saved['uid']}",
metadata={
"owner_kind": saved["owner_kind"],
"owner_id": saved["owner_id"],
"app_reference": saved["app_reference"],
"limit_usd": saved["limit_usd"],
},
target_type="gateway_quota_rule",
target_uid=saved["uid"],
)
print(f"Saved quota rule {saved['uid']}")
def cmd_gateway_quota_delete(args):
from devplacepy.services.openai_gateway import quota
if not quota.quota_rule_store.remove(args.uid):
print(f"Quota rule '{args.uid}' not found")
sys.exit(1)
_audit_cli(
"gateway.quota_rule.delete",
f"CLI deleted gateway quota rule {args.uid}",
target_type="gateway_quota_rule",
target_uid=args.uid,
)
print(f"Deleted quota rule {args.uid}")
def cmd_gateway_quota_reset(args):
from devplacepy.services.openai_gateway import quota
try:
payload = quota.QuotaResetIn(
owner_kind=args.owner_kind,
owner_id=args.owner_id,
app_reference=args.app_reference,
)
except Exception as exc:
print(f"Error: {exc}")
sys.exit(1)
scope = quota.reset(payload, created_by="cli")
label = quota.scope_label(scope, fallback="every caller")
_audit_cli(
"gateway.quota.reset",
f"CLI reset the gateway 24h spend for {label}",
target_type="gateway_quota",
target_uid=scope["uid"],
metadata={
"owner_kind": scope["owner_kind"],
"owner_id": scope["owner_id"],
"app_reference": scope["app_reference"],
"reset_at": scope["reset_at"],
},
)
print(f"Reset the rolling 24h spend for {label}")
def register_gateway(subparsers):
gateway = subparsers.add_parser("gateway", help="AI gateway management")
gateway_sub = gateway.add_subparsers(title="action", dest="action")
quota = gateway_sub.add_parser("quota", help="Manage rolling-24h AI gateway quota rules")
quota_sub = quota.add_subparsers(title="sub-action", dest="sub_action")
quota_list = quota_sub.add_parser("list", help="List all quota rules and their current 24h spend")
quota_list.set_defaults(func=cmd_gateway_quota_list)
quota_set = quota_sub.add_parser(
"set", help="Create or update a quota rule (scope by role/user/app, any combination)"
)
quota_set.add_argument("--uid", help="Existing rule uid to update; omit to create a new rule")
quota_set.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit for any role",
)
quota_set.add_argument("--owner-id", help="Specific user uid to scope by. Omit for any caller")
quota_set.add_argument("--app-reference", help="App label to scope by. Omit for any app")
quota_set.add_argument(
"--limit-usd", type=float, required=True, help="Rolling 24h USD cap (0 = unlimited)"
)
quota_set.add_argument("--label", help="Optional admin-facing note")
quota_set.add_argument("--inactive", action="store_true", help="Create the rule disabled")
quota_set.set_defaults(func=cmd_gateway_quota_set)
quota_delete = quota_sub.add_parser("delete", help="Delete a quota rule by uid")
quota_delete.add_argument("uid", help="Quota rule uid")
quota_delete.set_defaults(func=cmd_gateway_quota_delete)
quota_reset = quota_sub.add_parser(
"reset",
help="Clear the rolling-24h spend so a capped caller can call again (keeps the usage history)",
)
quota_reset.add_argument(
"--owner-kind",
choices=("internal", "key", "user", "admin", "anonymous"),
help="Role to scope by. Omit to reset every role",
)
quota_reset.add_argument("--owner-id", help="Specific user uid to scope by. Omit for every caller")
quota_reset.add_argument("--app-reference", help="App label to scope by. Omit for every app")
quota_reset.set_defaults(func=cmd_gateway_quota_reset)
+382
View File
@@ -0,0 +1,382 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def _remove_zip_artifacts(job):
import shutil
from pathlib import Path
from devplacepy.config import ZIP_STAGING_DIR
local_path = (job.get("result") or {}).get("local_path")
if local_path:
Path(local_path).unlink(missing_ok=True)
shutil.rmtree(ZIP_STAGING_DIR / job["uid"], ignore_errors=True)
def cmd_zips_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="zip", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
print(f"Pruned {removed} expired zip job(s)")
def cmd_zips_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="zip")
for job in jobs:
_remove_zip_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} zip job(s) and their archives")
def cmd_forks_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="fork", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
print(f"Pruned {removed} expired fork job(s)")
def cmd_forks_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="fork")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} fork job(s)")
def _remove_seo_artifacts(job):
import shutil
from devplacepy.config import SEO_REPORTS_DIR
shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True)
def cmd_seo_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed})
print(f"Pruned {removed} expired SEO audit(s)")
def cmd_seo_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo")
for job in jobs:
_remove_seo_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)})
print(f"Cleared {len(jobs)} SEO audit(s) and their reports")
def cmd_seo_meta_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="seo_meta", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.seo_meta.prune",
f"CLI pruned {removed} expired SEO metadata jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired SEO metadata job(s)")
def cmd_seo_meta_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="seo_meta")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.seo_meta.clear",
f"CLI cleared all SEO metadata jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} SEO metadata job(s); generated metadata persists")
def _remove_deepsearch_artifacts(job):
import shutil
from devplacepy.config import DEEPSEARCH_DIR
from devplacepy.services.deepsearch.store import VectorStore
uid = job["uid"]
collection = f"ds_{uid.replace('-', '')}"
VectorStore(collection).drop()
shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True)
def cmd_deepsearch_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="deepsearch", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli(
"cli.deepsearch.prune",
f"CLI pruned {removed} expired DeepSearch jobs",
metadata={"count": removed},
)
print(f"Pruned {removed} expired DeepSearch job(s)")
def cmd_deepsearch_clear(args):
from devplacepy.services.jobs import queue
jobs = queue.list_jobs(kind="deepsearch")
for job in jobs:
_remove_deepsearch_artifacts(job)
get_table("jobs").delete(uid=job["uid"])
_audit_cli(
"cli.deepsearch.clear",
f"CLI cleared all DeepSearch jobs ({len(jobs)})",
metadata={"count": len(jobs)},
)
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
def cmd_isslop_prune(args):
from datetime import datetime, timezone
from devplacepy.services.jobs import queue
now = datetime.now(timezone.utc)
removed = 0
for job in queue.list_jobs(kind="isslop", status=queue.DONE):
expires_at = job.get("expires_at")
if not expires_at:
continue
try:
expiry = datetime.fromisoformat(expires_at)
except (ValueError, TypeError):
continue
if expiry < now:
get_table("jobs").delete(uid=job["uid"])
removed += 1
_audit_cli("cli.isslop.prune", f"CLI pruned {removed} expired AI usage analysis jobs", metadata={"count": removed})
print(f"Pruned {removed} expired AI usage analysis job(s) (reports persist)")
def cmd_isslop_clear(args):
from devplacepy.services.jobs import queue
from devplacepy.services.jobs.isslop import store
jobs = queue.list_jobs(kind="isslop")
for job in jobs:
get_table("jobs").delete(uid=job["uid"])
analyses = list(get_table(store.TABLE_ANALYSES).find())
for analysis in analyses:
store.purge_analysis(analysis["uid"])
_audit_cli(
"cli.isslop.clear",
f"CLI cleared {len(analyses)} AI usage analyses and {len(jobs)} job rows",
metadata={"analyses": len(analyses), "jobs": len(jobs)},
)
print(f"Cleared {len(analyses)} AI usage analysis(es), their reports and {len(jobs)} job row(s)")
def cmd_isslop_analyze(args):
import asyncio
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key
from devplacepy.models import IsslopRunForm
from devplacepy.services.jobs.isslop import store
from devplacepy.services.jobs.isslop.acquisition.workspace import remove_workspace, workspace_for
from devplacepy.services.jobs.isslop.config import settings_from_payload
from devplacepy.services.jobs.isslop.events import KIND_DONE, KIND_ERROR
from devplacepy.services.jobs.isslop.persistence import EventPersister
from devplacepy.services.jobs.isslop.pipeline import run_pipeline
from devplacepy.utils import generate_uid
url = IsslopRunForm(url=args.url).url
ensure_data_dirs()
uid = generate_uid()
settings = settings_from_payload(
{
"url": url,
"llm_endpoint": INTERNAL_GATEWAY_URL,
"api_key": internal_gateway_key(),
"allow_private": bool(args.allow_private),
"media_dir": str(store.media_dir_for(uid)),
}
)
store.create_analysis(uid, url, "system", "cli")
persister = EventPersister(uid)
store.update_analysis(uid, status="running")
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, url, uid)
async def run() -> int:
failed = False
try:
async for event in run_pipeline(url, workspace, settings):
persister.apply(event)
if args.json:
print(event.to_json(), flush=True)
else:
print(f"[{event.kind}] {event.message}", flush=True)
if event.kind == KIND_ERROR:
failed = True
if event.kind == KIND_DONE and not args.json:
print(f"Report: /tools/isslop/{uid}/report")
print(f"Badge: /tools/isslop/{uid}/badge.svg")
finally:
remove_workspace(workspace)
return 1 if failed else 0
exit_code = asyncio.run(run())
_audit_cli(
"cli.isslop.analyze",
f"CLI AI usage analysis of {url}",
metadata={"uid": uid, "failed": bool(exit_code)},
)
raise SystemExit(exit_code)
def register_jobs(subparsers):
zips = subparsers.add_parser("zips", help="Zip archive job management")
zips_sub = zips.add_subparsers(title="action", dest="action")
zips_prune = zips_sub.add_parser(
"prune", help="Delete expired zip archives and their job rows"
)
zips_prune.set_defaults(func=cmd_zips_prune)
zips_clear = zips_sub.add_parser(
"clear", help="Delete every zip archive and job row"
)
zips_clear.set_defaults(func=cmd_zips_clear)
forks = subparsers.add_parser("forks", help="Fork job management")
forks_sub = forks.add_subparsers(title="action", dest="action")
forks_prune = forks_sub.add_parser(
"prune", help="Delete expired completed fork job rows (forked projects persist)"
)
forks_prune.set_defaults(func=cmd_forks_prune)
forks_clear = forks_sub.add_parser(
"clear", help="Delete every fork job row (forked projects persist)"
)
forks_clear.set_defaults(func=cmd_forks_clear)
seo = subparsers.add_parser("seo", help="SEO Diagnostics job management")
seo_sub = seo.add_subparsers(title="action", dest="action")
seo_prune = seo_sub.add_parser(
"prune", help="Delete expired SEO audit reports and their job rows"
)
seo_prune.set_defaults(func=cmd_seo_prune)
seo_clear = seo_sub.add_parser(
"clear", help="Delete every SEO audit report and job row"
)
seo_clear.set_defaults(func=cmd_seo_clear)
seo_meta = subparsers.add_parser("seo-meta", help="SEO metadata job management")
seo_meta_sub = seo_meta.add_subparsers(title="action", dest="action")
seo_meta_prune = seo_meta_sub.add_parser(
"prune", help="Delete expired SEO metadata job rows (generated metadata persists)"
)
seo_meta_prune.set_defaults(func=cmd_seo_meta_prune)
seo_meta_clear = seo_meta_sub.add_parser(
"clear", help="Delete every SEO metadata job row (generated metadata persists)"
)
seo_meta_clear.set_defaults(func=cmd_seo_meta_clear)
deepsearch = subparsers.add_parser("deepsearch", help="DeepSearch job management")
deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action")
deepsearch_prune = deepsearch_sub.add_parser(
"prune", help="Delete expired DeepSearch sessions and their job rows"
)
deepsearch_prune.set_defaults(func=cmd_deepsearch_prune)
deepsearch_clear = deepsearch_sub.add_parser(
"clear", help="Delete every DeepSearch session and job row"
)
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)
isslop = subparsers.add_parser("isslop", help="AI Usage Analyzer job management")
isslop_sub = isslop.add_subparsers(title="action", dest="action")
isslop_prune = isslop_sub.add_parser(
"prune", help="Delete expired AI usage analysis job rows (analyses and reports persist)"
)
isslop_prune.set_defaults(func=cmd_isslop_prune)
isslop_clear = isslop_sub.add_parser(
"clear", help="Delete every AI usage analysis, its report and job rows"
)
isslop_clear.set_defaults(func=cmd_isslop_clear)
isslop_analyze = isslop_sub.add_parser(
"analyze", help="Run a AI usage analysis from the terminal and persist its report"
)
isslop_analyze.add_argument("url", help="Repository or website URL to classify")
isslop_analyze.add_argument("--json", action="store_true", help="Emit raw JSON events")
isslop_analyze.add_argument("--allow-private", action="store_true", dest="allow_private", help="Permit private and loopback hosts")
isslop_analyze.set_defaults(func=cmd_isslop_analyze)
+56
View File
@@ -0,0 +1,56 @@
# retoor <retoor@molodetz.nl>
import argparse
import sys
from devplacepy.cli.accounts import register_accounts
from devplacepy.cli.roles import register_roles
from devplacepy.cli.apikeys import register_apikeys
from devplacepy.cli.tokens import register_tokens
from devplacepy.cli.news import register_news
from devplacepy.cli.attachments import register_attachments
from devplacepy.cli.devii import register_devii
from devplacepy.cli.jobs import register_jobs
from devplacepy.cli.backups import register_backups
from devplacepy.cli.containers import register_containers
from devplacepy.cli.migrate import register_migrate
from devplacepy.cli.game import register_game
from devplacepy.cli.quiz import register_quiz
from devplacepy.cli.gateway import register_gateway
from devplacepy.cli.messaging import register_messaging
def build_parser():
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
sub = parser.add_subparsers(title="commands", dest="command")
register_roles(sub)
register_apikeys(sub)
register_tokens(sub)
register_news(sub)
register_attachments(sub)
register_devii(sub)
register_jobs(sub)
register_backups(sub)
register_containers(sub)
register_migrate(sub)
register_game(sub)
register_quiz(sub)
register_gateway(sub)
register_messaging(sub)
register_accounts(sub)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_messaging_prune_tickets(args):
from datetime import datetime, timezone
from devplacepy.database import get_table
now = datetime.now(timezone.utc).isoformat()
tickets = get_table("ws_tickets")
expired = list(tickets.find(expires_at={"<": now}))
for ticket in expired:
tickets.delete(uid=ticket["uid"])
_audit_cli(
"cli.messaging.prune_tickets",
f"CLI pruned {len(expired)} expired WS tickets",
metadata={"count": len(expired)},
)
print(f"Pruned {len(expired)} expired WS ticket(s)")
def register_messaging(subparsers):
messaging = subparsers.add_parser("messaging", help="Messaging WS ticket management")
messaging_sub = messaging.add_subparsers(title="action", dest="action")
messaging_prune_tickets = messaging_sub.add_parser(
"prune-tickets", help="Delete expired WebSocket auth tickets"
)
messaging_prune_tickets.set_defaults(func=cmd_messaging_prune_tickets)
+233
View File
@@ -0,0 +1,233 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.cli._shared import _audit_cli
def _crc32(path):
import zlib
crc = 0
with open(path, "rb") as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
crc = zlib.crc32(chunk, crc)
return crc & 0xFFFFFFFF
def _migrate_file(source, dest, dry_run, report):
import os
import shutil
if not source.exists():
return
if source.resolve() == dest.resolve():
return
size = source.stat().st_size
if dest.exists():
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
report.append(("done", source, dest, size))
if not dry_run:
source.unlink()
return
report.append(("conflict", source, dest, size))
return
report.append(("move", source, dest, size))
if dry_run:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(dest.name + ".migrating")
shutil.copyfile(source, tmp)
with open(tmp, "rb") as handle:
os.fsync(handle.fileno())
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
tmp.unlink(missing_ok=True)
raise RuntimeError(f"verification failed copying {source} -> {dest}")
os.replace(tmp, dest)
source.unlink()
def _prune_empty_dirs(root):
if not root.exists():
return
for path in sorted(root.rglob("*"), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
try:
root.rmdir()
except OSError:
pass
def _migrate_tree(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
for child in sorted(source.rglob("*")):
if child.is_file():
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
if not dry_run:
_prune_empty_dirs(source)
def _db_is_locked(path):
import sqlite3
try:
conn = sqlite3.connect(str(path), timeout=0.5)
try:
conn.execute("BEGIN IMMEDIATE")
conn.rollback()
return False
finally:
conn.close()
except sqlite3.OperationalError:
return True
def _checkpoint(path):
import sqlite3
conn = sqlite3.connect(str(path), timeout=5)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.commit()
finally:
conn.close()
def _migrate_db(source, dest, dry_run, report):
if not source.exists():
return
if source.resolve() == dest.resolve():
return
if _db_is_locked(source):
raise RuntimeError(
f"{source} is locked - stop the app before running migrate-data"
)
if not dry_run:
_checkpoint(source)
_migrate_file(source, dest, dry_run, report)
for suffix in ("-wal", "-shm"):
_migrate_file(
source.with_name(source.name + suffix),
dest.with_name(dest.name + suffix),
dry_run,
report,
)
def cmd_emoji_sync(args):
from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module
count = write_emoji_module()
_audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count})
print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}")
def cmd_migrate_data(args):
import os
from pathlib import Path
from collections import Counter
from devplacepy import config
base = config.BASE_DIR
home = Path.home()
dry = args.dry_run
report = []
config.ensure_data_dirs()
db_items = []
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
else:
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
if not os.environ.get("DEVII_TASKS_DB"):
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
if not os.environ.get("DEVII_LESSONS_DB"):
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
file_items = [
(base / name, config.KEYS_DIR / name)
for name in (
"notification-private.pem",
"notification-private.pkcs8.pem",
"notification-public.pem",
)
]
registry_dest = config.BOT_DIR / "article_registry.json"
registry_sources = [
path
for path in (
home / ".dpbot_article_registry.json",
base / ".dpbot_article_registry.json",
)
if path.exists()
]
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
if registry_sources:
file_items.append((registry_sources[0], registry_dest))
for stale in registry_sources[1:]:
print(f"Leaving older duplicate registry untouched: {stale}")
legacy_var = base / "var"
tree_items = [
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
(home / ".devplace_bots", config.BOT_DIR),
]
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
try:
for source, dest in db_items:
_migrate_db(source, dest, dry, report)
for source, dest in file_items:
_migrate_file(source, dest, dry, report)
for source, dest in tree_items:
_migrate_tree(source, dest, dry, report)
except RuntimeError as exc:
print(f"ERROR: {exc}")
sys.exit(1)
if not report:
print("Nothing to migrate; the data directory is already consolidated.")
return
for status, source, dest, size in report:
print(f" [{status}] {source} -> {dest} ({size} bytes)")
counts = Counter(status for status, *_ in report)
print()
print(
("Planned: " if dry else "Migrated: ")
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
)
if any(status == "conflict" for status, *_ in report):
print(
"Conflicts left both source and destination untouched; resolve them by hand."
)
if dry:
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
def register_migrate(subparsers):
subparsers.add_parser(
"emoji-sync",
help="Regenerate static/js/emoji-shortcodes.js from the emoji library",
).set_defaults(func=cmd_emoji_sync)
migrate = subparsers.add_parser(
"migrate-data",
help="Relocate legacy runtime files into the consolidated data/ directory",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Print the source-to-destination plan without changing anything",
)
migrate.set_defaults(func=cmd_migrate_data)
+53
View File
@@ -0,0 +1,53 @@
# retoor <retoor@molodetz.nl>
from devplacepy.utils import strip_html
from devplacepy.cli._shared import _audit_cli
def cmd_news_clear(args):
from devplacepy.database import db
deleted = {}
for table in ("news", "news_images", "news_sync"):
if table in db.tables:
count = db[table].count()
db[table].delete()
deleted[table] = count
print(f"Deleted {count} rows from '{table}'")
else:
print(f"Table '{table}' does not exist, skipping")
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
print("News data cleared")
def cmd_news_sanitize(args):
from devplacepy.database import db
if "news" not in db.tables:
print("News table does not exist")
return
news_table = db["news"]
updated = 0
for row in news_table.all():
desc = (strip_html(row.get("description", "") or ""))[:5000]
content = (strip_html(row.get("content", "") or ""))[:10000]
if desc != row.get("description", "") or content != row.get("content", ""):
news_table.update(
{"id": row["id"], "description": desc, "content": content}, ["id"]
)
updated += 1
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
print(f"Sanitized {updated} news article(s)")
def register_news(subparsers):
news = subparsers.add_parser("news", help="News management")
news_sub = news.add_subparsers(title="action", dest="action")
news_clear = news_sub.add_parser(
"clear", help="Delete all news from local database"
)
news_clear.set_defaults(func=cmd_news_clear)
news_sanitize = news_sub.add_parser(
"sanitize", help="Strip HTML from all existing news descriptions and content"
)
news_sanitize.set_defaults(func=cmd_news_sanitize)
+31
View File
@@ -0,0 +1,31 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def cmd_quiz_prune(args):
from datetime import datetime, timedelta, timezone
from devplacepy.config import QUIZ_ATTEMPT_RETENTION_DAYS
from devplacepy.services.quiz import store
cutoff = (
datetime.now(timezone.utc) - timedelta(days=QUIZ_ATTEMPT_RETENTION_DAYS)
).isoformat()
removed = store.prune_attempts(cutoff)
_audit_cli(
"cli.quiz.prune",
f"CLI pruned {removed} abandoned quiz attempt(s)",
metadata={"count": removed, "retention_days": QUIZ_ATTEMPT_RETENTION_DAYS},
)
print(f"Pruned {removed} abandoned or expired quiz attempt(s)")
def register_quiz(subparsers):
quiz = subparsers.add_parser("quiz", help="Quiz management")
quiz_sub = quiz.add_subparsers(title="action", dest="action")
prune = quiz_sub.add_parser(
"prune",
help="Delete abandoned and expired attempts older than the retention window",
)
prune.set_defaults(func=cmd_quiz_prune)
+57
View File
@@ -0,0 +1,57 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table, invalidate_admins_cache
from devplacepy.cli._shared import _audit_cli
def cmd_role_get(args):
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
print(user.get("role", "member").lower())
def cmd_role_set(args):
role = args.role.lower()
if role not in ("member", "admin"):
print("Role must be 'member' or 'admin'")
sys.exit(1)
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
old_role = user.get("role")
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
invalidate_admins_cache()
from devplacepy.services.audit import record as audit
_audit_cli(
"cli.role.set",
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
metadata={"old": old_role, "new": role.capitalize()},
target_type="user",
target_uid=user["uid"],
target_label=args.username,
links=[audit.target("user", user["uid"], args.username)],
)
print(f"User '{args.username}' role set to '{role}'")
def register_roles(subparsers):
role = subparsers.add_parser("role", help="Manage user roles")
role_sub = role.add_subparsers(title="action", dest="action")
role_get = role_sub.add_parser("get", help="Get a user's role")
role_get.add_argument("username")
role_get.set_defaults(func=cmd_role_get)
role_set = role_sub.add_parser("set", help="Set a user's role")
role_set.add_argument("username")
role_set.add_argument("role", choices=["member", "admin"])
role_set.set_defaults(func=cmd_role_set)
+125
View File
@@ -0,0 +1,125 @@
# retoor <retoor@molodetz.nl>
import sys
from devplacepy.database import get_table
from devplacepy.cli._shared import _audit_cli
def cmd_token_issue(args):
from devplacepy.services.access_tokens import issue_token
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
result = issue_token(user, label=args.label or "cli")
_audit_cli(
"cli.token.issue",
f"CLI issued access token for {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
metadata={"token_uid": result["uid"]},
)
print(result["access_token"])
def cmd_token_list(args):
from datetime import datetime, timezone
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
tokens = get_table("access_tokens")
now = datetime.now(timezone.utc)
found = False
for t in tokens.find(user_uid=user["uid"], deleted_at=None):
found = True
expires_at = t.get("expires_at", "")
try:
expires = datetime.fromisoformat(expires_at)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
status = "expired" if expires < now else "active"
except (ValueError, TypeError):
status = "unknown"
label = t.get("label", "") or "-"
print(
f" uid={t['uid']} token={t['token'][:12]}... "
f"label={label} expires={expires_at} status={status}"
)
if not found:
print(f"No active tokens for '{args.username}'")
def cmd_token_revoke(args):
from devplacepy.services.access_tokens import revoke_token
ok = revoke_token(args.token_uid)
if not ok:
print(f"Token uid='{args.token_uid}' not found or already revoked")
sys.exit(1)
_audit_cli(
"cli.token.revoke",
f"CLI revoked access token uid={args.token_uid}",
metadata={"token_uid": args.token_uid},
)
print(f"Revoked token uid='{args.token_uid}'")
def cmd_token_revoke_all(args):
from devplacepy.services.access_tokens import revoke_all
users = get_table("users")
user = users.find_one(username=args.username)
if not user:
print(f"User '{args.username}' not found")
sys.exit(1)
count = revoke_all(user["uid"])
_audit_cli(
"cli.token.revoke_all",
f"CLI revoked all access tokens for {args.username}",
target_type="user",
target_uid=user["uid"],
target_label=args.username,
metadata={"count": count},
)
print(f"Revoked {count} token(s) for '{args.username}'")
def cmd_token_prune(args):
from devplacepy.services.access_tokens import prune_expired
count = prune_expired()
_audit_cli(
"cli.token.prune",
f"CLI pruned {count} expired access tokens",
metadata={"count": count},
)
print(f"Pruned {count} expired token(s)")
def register_tokens(subparsers):
token = subparsers.add_parser("token", help="Manage DevPlace access tokens")
token_sub = token.add_subparsers(title="action", dest="action")
token_issue = token_sub.add_parser("issue", help="Issue an access token for a user")
token_issue.add_argument("username")
token_issue.add_argument("--label", default="cli", help="Optional label for the token")
token_issue.set_defaults(func=cmd_token_issue)
token_list = token_sub.add_parser("list", help="List a user's active access tokens")
token_list.add_argument("username")
token_list.set_defaults(func=cmd_token_list)
token_revoke = token_sub.add_parser("revoke", help="Revoke a single access token by uid")
token_revoke.add_argument("token_uid")
token_revoke.set_defaults(func=cmd_token_revoke)
token_revoke_all = token_sub.add_parser("revoke-all", help="Revoke all access tokens for a user")
token_revoke_all.add_argument("username")
token_revoke_all.set_defaults(func=cmd_token_revoke_all)
token_prune = token_sub.add_parser("prune", help="Soft-delete all expired access tokens")
token_prune.set_defaults(func=cmd_token_prune)
+63 -27
View File
@@ -11,22 +11,26 @@ BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "devplacepy" / "static"
TEMPLATES_DIR = BASE_DIR / "devplacepy" / "templates"
# Single source of truth for every runtime/user-generated artifact. Everything the
# app creates or modifies at runtime lives under DATA_DIR, never inside the package
# and never served via /static. Overridable by DEVPLACE_DATA_DIR (point at a volume
# in production). Each path below is derived from DATA_DIR; no other module computes
# a runtime path from scratch.
DATA_DIR = Path(environ.get("DEVPLACE_DATA_DIR", str(BASE_DIR / "data")))
UPLOADS_DIR = DATA_DIR / "uploads"
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
PROJECT_FILES_DIR = UPLOADS_DIR / "project_files"
CONTAINER_WORKSPACES_DIR = DATA_DIR / "container_workspaces"
WORKSPACE_STATE_DIR = DATA_DIR / "workspace_state"
ZIPS_DIR = DATA_DIR / "zips"
ZIP_STAGING_DIR = DATA_DIR / "zip_staging"
FORK_STAGING_DIR = DATA_DIR / "fork_staging"
BACKUPS_DIR = DATA_DIR / "backups"
BACKUP_STAGING_DIR = DATA_DIR / "backup_staging"
SEO_REPORTS_DIR = DATA_DIR / "seo_reports"
PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
DBAPI_DIR = DATA_DIR / "dbapi"
DEEPSEARCH_DIR = DATA_DIR / "deepsearch"
DEEPSEARCH_CHROMA_DIR = DEEPSEARCH_DIR / "chroma"
ISSLOP_DIR = DATA_DIR / "isslop"
ISSLOP_WORKSPACES_DIR = ISSLOP_DIR / "workspaces"
ISSLOP_RUNS_DIR = ISSLOP_DIR / "runs"
ISSLOP_MEDIA_DIR = ISSLOP_DIR / "media"
KEYS_DIR = DATA_DIR / "keys"
BOT_DIR = DATA_DIR / "bot"
LOCKS_DIR = DATA_DIR / "locks"
@@ -44,23 +48,19 @@ SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
# Forking XML-RPC bridge. A standalone ForkingMixIn XML-RPC server binds this
# port on loopback and translates every documented REST endpoint into an XML-RPC
# method (generated from docs_api.API_GROUPS). The FastAPI app reverse-proxies
# /xmlrpc to it; nginx forwards /xmlrpc in production. The bind host stays
# loopback because the app and nginx are the only intended front doors.
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
PRESENCE_WRITE_SECONDS = max(1, PRESENCE_TIMEOUT_SECONDS // 2)
PRESENCE_ONLINE_LIMIT = int(environ.get("DEVPLACE_PRESENCE_ONLINE_LIMIT", "30"))
PRESENCE_TRACK_LIMIT = int(environ.get("DEVPLACE_PRESENCE_TRACK_LIMIT", "500"))
PRESENCE_ONLINE_MARGIN_SECONDS = int(
environ.get("DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS", "20")
)
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
# Cache-busting version stamped onto every app-owned static URL. Computed once at
# process start, so a restart/deploy changes it and busts every browser cache while
# assets stay heavily cached (immutable, 1 year) between deploys. Set
# DEVPLACE_STATIC_VERSION at launch so multiple prod workers share one value.
STATIC_VERSION = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
# Jinja template auto-reload stat-checks every template in the inheritance chain on
# every render. Keep it on for development hot-reload; turn it off in production
# (DEVPLACE_TEMPLATE_AUTO_RELOAD=0) so compiled templates stay cached in memory.
TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0"
INTERNAL_BASE_URL = environ.get(
@@ -70,40 +70,76 @@ INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions"
INTERNAL_EMBED_URL = f"{INTERNAL_BASE_URL}/openai/v1/embeddings"
INTERNAL_MODEL = "molodetz"
INTERNAL_EMBED_MODEL = "molodetz~embed"
INTERNAL_IMAGE_MODEL = "molodetz-img-small"
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT = 24
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT = 24
AWARD_DISPLAY_HOURS_DEFAULT = 24
AWARD_DESCRIPTION_MAX = 125
AWARD_IMAGE_MODEL_DEFAULT = "molodetz-img-small"
AWARD_IMAGE_SIZE_DEFAULT = "512x512"
AWARD_GENERATION_TIMEOUT_SECONDS = 120.0
AWARD_IMAGE_PROMPT_DEFAULT = (
"Generate a single decorative developer award emblem/badge as a PNG with a fully "
"transparent background (alpha channel). No rectangular backdrop, no drop shadow "
"plate, no text labels rendered in the image. Center one stylized trophy/medal "
"icon that visually matches this message:"
)
QUIZ_ANSWER_MAX_CHARS = 2000
QUIZ_FEEDBACK_MAX_CHARS = 400
QUIZ_MAX_QUESTIONS = 100
QUIZ_MAX_OPTIONS = 12
QUIZ_MAX_TIME_LIMIT_SECONDS = 86400
QUIZ_AI_CORRECT_THRESHOLD = 0.5
QUIZ_GRADING_TIMEOUT_SECONDS = 45.0
QUIZ_ATTEMPT_RETENTION_DAYS = 90
QUIZ_SCOREBOARD_LIMIT = 20
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
QUIZ_LIST_PER_PAGE = 20
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
DEFAULT_MODIFIER_PROMPT = (
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
)
SERVICE_LOCK_FILE = LOCKS_DIR / "devplace-services.lock"
INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
# Every container instance runs this one prebuilt image (built once via `make ppy`).
CONTAINER_IMAGE = environ.get("DEVPLACE_CONTAINER_IMAGE", "ppy:latest")
# Override host the /p/<slug> ingress proxy dials, with the published host port,
# instead of the container's own bridge IP. Set this only for topologies where
# the app cannot route to the container network directly (containerized app via
# docker socket: use host.docker.internal). Left empty, the proxy connects
# straight to the container IP and container port, which avoids the host
# port-publishing layer (docker-proxy / iptables DNAT / loopback) entirely.
CONTAINER_PROXY_HOST = environ.get("DEVPLACE_CONTAINER_PROXY_HOST", "").strip()
WORKSPACE_TUNNEL_DOMAIN = environ.get(
"DEVPLACE_WORKSPACE_TUNNEL_DOMAIN", "tunnel.pravda.education"
).strip()
WORKSPACE_ACTIVITY_WRITE_SECONDS = 30
WORKSPACE_METRICS_RING = 720
VAPID_PRIVATE_KEY_FILE = KEYS_DIR / "notification-private.pem"
VAPID_PRIVATE_KEY_PKCS8_FILE = KEYS_DIR / "notification-private.pkcs8.pem"
VAPID_PUBLIC_KEY_FILE = KEYS_DIR / "notification-public.pem"
VAPID_SUB = environ.get("DEVPLACE_VAPID_SUB", "mailto:retoor@molodetz.nl")
# Documented registry of every runtime directory. ensure_data_dirs() creates them
# all at startup so the tree always exists before the DB, keys, locks, uploads, and
# job staging are written.
DATA_PATHS: dict[str, Path] = {
"data": DATA_DIR,
"uploads": UPLOADS_DIR,
"attachments": ATTACHMENTS_DIR,
"project_files": PROJECT_FILES_DIR,
"container_workspaces": CONTAINER_WORKSPACES_DIR,
"workspace_state": WORKSPACE_STATE_DIR,
"zips": ZIPS_DIR,
"zip_staging": ZIP_STAGING_DIR,
"fork_staging": FORK_STAGING_DIR,
"backups": BACKUPS_DIR,
"backup_staging": BACKUP_STAGING_DIR,
"seo_reports": SEO_REPORTS_DIR,
"planning_reports": PLANNING_REPORTS_DIR,
"dbapi": DBAPI_DIR,
"deepsearch": DEEPSEARCH_DIR,
"deepsearch_chroma": DEEPSEARCH_CHROMA_DIR,
"isslop": ISSLOP_DIR,
"isslop_workspaces": ISSLOP_WORKSPACES_DIR,
"isslop_runs": ISSLOP_RUNS_DIR,
"isslop_media": ISSLOP_MEDIA_DIR,
"keys": KEYS_DIR,
"bot": BOT_DIR,
"locks": LOCKS_DIR,
+1 -1
View File
@@ -1,6 +1,6 @@
# retoor <retoor@molodetz.nl>
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
REACTION_EMOJI = [
"\U0001f44d",
+283 -5
View File
@@ -13,11 +13,17 @@ from devplacepy.database import (
resolve_by_slug,
get_users_by_uids,
get_vote_counts,
get_comment_counts_by_post_uids,
paginate,
STAR_TARGETS,
get_user_votes,
get_reactions_by_targets,
get_user_bookmarks,
get_blocked_uids,
get_poll_for_post,
update_target_stars,
clear_user_stars,
clear_user_post_count,
get_target_owner_uid,
resolve_object_url,
soft_delete,
@@ -25,6 +31,11 @@ from devplacepy.database import (
soft_delete_engagement,
soft_delete_fork_relations,
load_comments,
band_allows_mature,
band_allows_restricted,
get_maturity,
get_maturity_by_targets,
get_int_setting,
_now_iso,
db,
)
@@ -33,34 +44,171 @@ from devplacepy.utils import (
generate_uid,
make_combined_slug,
award_rewards,
track_action,
create_notification,
create_mention_notifications,
is_admin,
is_primary_admin,
XP_COMMENT,
XP_UPVOTE,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
from devplacepy.services.moderation.screening import (
record as record_screening,
refuse_if_blocked,
screen_fields,
)
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news", "quiz"}
REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
logger = logging.getLogger(__name__)
def get_project_by_uid(project_uid: str | None) -> dict | None:
if not project_uid:
return None
project = get_table("projects").find_one(uid=project_uid)
if not project:
return None
slug = project.get("slug") or project["uid"]
return {
"uid": project["uid"],
"name": project.get("title") or project.get("name", ""),
"slug": slug,
"url": f"/projects/{slug}",
}
def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"])
def mature_hidden_by_default() -> bool:
return get_int_setting("moderation_mature_default_hidden", 1) != 0
def maturity_hidden(level: str | None, user: dict | None) -> bool:
if not level or level == "general":
return False
if not mature_hidden_by_default():
return False
if not user:
return True
band = user.get("age_band") or "adult"
allowed = (
band_allows_restricted(band) if level == "restricted" else band_allows_mature(band)
)
return not (allowed and bool(user.get("mature_opt_in")))
def is_suspended(user: dict | None) -> bool:
from devplacepy.database import suspension_active
return suspension_active(user)
def _owner_is_admin(project: dict) -> bool:
owner_uid = project.get("user_uid")
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
return is_admin(owner)
def can_view_project(project: dict | None, user: dict | None) -> bool:
if not project:
return False
if not project.get("is_private"):
return True
if is_owner(project, user):
return True
if not is_admin(user):
return False
return not _owner_is_admin(project)
def owns_instance(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not user:
return False
uid = user.get("uid")
if not uid:
return False
if instance.get("created_by") == uid:
return True
return bool(project and project.get("user_uid") == uid)
def can_view_project_containers(project: dict | None, user: dict | None) -> bool:
if not project or not is_admin(user):
return False
if is_primary_admin(user) or is_owner(project, user):
return True
return not project.get("is_private")
def can_view_instance(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not is_admin(user):
return False
if is_primary_admin(user) or owns_instance(instance, project, user):
return True
return bool(project) and not project.get("is_private")
def can_manage_instance(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not is_admin(user):
return False
return is_primary_admin(user) or owns_instance(instance, project, user)
def workspaces_enabled() -> bool:
from devplacepy.database import get_setting
return get_setting("workspace_enabled", "0") == "1"
def can_open_workspace(project: dict | None, user: dict | None) -> bool:
if not project or not user or not user.get("uid"):
return False
if not workspaces_enabled():
return False
return is_owner(project, user) or is_admin(user)
def owns_workspace(instance: dict | None, user: dict | None) -> bool:
if not instance or not user:
return False
uid = user.get("uid")
if not uid:
return False
return instance.get("workspace_owner_uid") == uid
def can_manage_workspace(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
if not instance or not user:
return False
if owns_workspace(instance, user):
return True
return can_manage_instance(instance, project, user)
def can_manage_tunnel(
instance: dict | None, project: dict | None, user: dict | None
) -> bool:
return can_manage_workspace(instance, project, user)
def canonical_redirect(
area: str, item: dict, requested: str
) -> RedirectResponse | None:
@@ -92,6 +240,8 @@ def create_content_item(
attachment_uids: list | None,
request=None,
) -> tuple[str, str]:
screening = screen_fields(table_name, fields)
refuse_if_blocked(screening)
uid = generate_uid()
slug = make_combined_slug(slug_source, uid)
get_table(table_name).insert(
@@ -106,6 +256,12 @@ def create_content_item(
**fields,
}
)
if table_name == "posts":
clear_user_post_count(user["uid"])
if table_name == "projects":
from devplacepy.templating import clear_user_projects_cache
clear_user_projects_cache(user["uid"])
award_rewards(user["uid"], xp, badge)
if attachment_uids:
link_attachments(attachment_uids, target_type, uid)
@@ -131,10 +287,20 @@ def create_content_item(
metadata=metadata or None,
links=links,
)
record_screening(
screening,
target_type=target_type,
target_uid=uid,
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, table_name, uid, request)
schedule_modification(user, table_name, uid, request)
schedule_seo_meta_for_table(table_name, uid)
return uid, slug
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project"}
VOTE_NOTIFY_TYPES = {"post", "comment", "gist", "project", "quiz"}
def apply_vote(request, user: dict, target_type: str, target_uid: str, value: int) -> dict:
@@ -191,6 +357,8 @@ def apply_vote(request, user: dict, target_type: str, target_uid: str, value: in
update_target_stars(target_type, target_uid, net)
owner_uid = get_target_owner_uid(target_type, target_uid)
if owner_uid:
clear_user_stars(owner_uid)
direction = "clear" if new_value == 0 else ("up" if new_value == 1 else "down")
vote_links = [audit.target(target_type, target_uid)]
if owner_uid and owner_uid != user["uid"]:
@@ -220,6 +388,9 @@ def apply_vote(request, user: dict, target_type: str, target_uid: str, value: in
)
award_rewards(owner_uid, XP_UPVOTE)
if did_upvote:
track_action(user["uid"], "vote")
current = votes.find_one(
user_uid=user["uid"],
target_uid=target_uid,
@@ -239,6 +410,8 @@ def create_comment_record(
parent_uid: str | None = None,
attachment_uids: list | None = None,
) -> tuple[str, str]:
screening = screen_fields("comments", {"content": content})
refuse_if_blocked(screening)
comment_uid = generate_uid()
redirect_url = resolve_object_url(target_type, target_uid)
insert = {
@@ -289,6 +462,15 @@ def create_comment_record(
)
create_mention_notifications(content, user["uid"], comment_url)
record_screening(
screening,
target_type="comment",
target_uid=comment_uid,
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, "comments", comment_uid, request)
schedule_modification(user, "comments", comment_uid, request)
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
comment_links = [
audit.target("comment", comment_uid),
@@ -308,6 +490,40 @@ def create_comment_record(
return comment_uid, comment_url
def edit_comment_record(request, user: dict, comment: dict, content: str) -> str:
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
screening = screen_fields("comments", {"content": content})
refuse_if_blocked(screening)
updated_at = datetime.now(timezone.utc).isoformat()
get_table("comments").update(
{"uid": comment["uid"], "content": content, "updated_at": updated_at}, ["uid"]
)
record_screening(
screening,
target_type="comment",
target_uid=comment["uid"],
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, "comments", comment["uid"], request)
schedule_modification(user, "comments", comment["uid"], request)
logger.info(f"Comment {comment['uid']} edited by {user['username']}")
audit.record(
request,
"comment.edit",
user=user,
target_type="comment",
target_uid=comment["uid"],
summary=f"{user['username']} edited a comment under {target_type} {target_uid}",
links=[
audit.target("comment", comment["uid"]),
audit.parent(target_type, target_uid),
],
)
return updated_at
def delete_comment_record(request, user: dict, comment: dict) -> tuple[str, str]:
target_type = comment.get("target_type", "post")
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
@@ -383,6 +599,8 @@ def set_bookmark(
summary=f"{user['username']} {'bookmarked' if saved else 'removed bookmark from'} {target_type} {target_uid}",
links=[audit.target(target_type, target_uid)],
)
if saved:
track_action(user["uid"], "bookmark")
return saved
@@ -409,6 +627,8 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
"project_link": detail.get("project_link"),
"maturity": detail.get("maturity", "general"),
}
if extra:
context.update(extra)
@@ -443,11 +663,23 @@ def edit_content_item(
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(url=redirect_fail, status_code=302)
screening = screen_fields(table_name, update_fields)
refuse_if_blocked(screening)
update_fields = {
**update_fields,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
table.update({"uid": item["uid"], **update_fields}, ["uid"])
record_screening(
screening,
target_type=kind,
target_uid=item["uid"],
actor_uid=user["uid"],
request=request,
)
schedule_correction(user, table_name, item["uid"], request)
schedule_modification(user, table_name, item["uid"], request)
schedule_seo_meta_for_table(table_name, item["uid"], regenerate=True)
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
label = update_fields.get("title") or item.get("title") or item["uid"]
audit.record(
@@ -515,11 +747,20 @@ def delete_content_item(
soft_delete_engagement(target_type, [item["uid"]], actor)
if comment_uids:
soft_delete_engagement("comment", comment_uids, actor)
if target_type == "post":
clear_user_post_count(item["user_uid"])
if target_type == "quiz":
from devplacepy.services.quiz.store import cascade_questions, clear_cache
cascade_questions(item["uid"], actor, stamp)
clear_cache()
if target_type == "project":
from devplacepy.project_files import soft_delete_all_project_files
from devplacepy.templating import clear_user_projects_cache
soft_delete_all_project_files(item["uid"], actor)
soft_delete_fork_relations(item["uid"], actor)
clear_user_projects_cache(item["user_uid"])
soft_delete(table_name, actor, stamp=stamp, uid=item["uid"])
logger.info(f"{table_name} {item['uid']} soft-deleted by {user['username']}")
audit.record(
@@ -542,8 +783,14 @@ def load_detail(
item = resolve_by_slug(get_table(table_name), slug)
if not item:
return None
if user and item["user_uid"] in get_blocked_uids(user["uid"]):
return None
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
ups, downs = get_vote_counts([item["uid"]])
if target_type in STAR_TARGETS:
star_count = item.get("stars") or 0
else:
ups, downs = get_vote_counts([item["uid"]])
star_count = ups.get(item["uid"], 0) - downs.get(item["uid"], 0)
reactions = (
get_reactions_by_targets(target_type, [item["uid"]], user).get(
item["uid"], {"counts": {}, "mine": []}
@@ -560,7 +807,7 @@ def load_detail(
"item": item,
"author": author,
"is_owner": bool(user and user["uid"] == item["user_uid"]),
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
"star_count": star_count,
"my_vote": get_user_votes(user["uid"], [item["uid"]]).get(item["uid"], 0)
if user
else 0,
@@ -570,6 +817,8 @@ def load_detail(
"reactions": reactions,
"bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
"maturity": get_maturity(target_type, item["uid"])["level"],
}
@@ -585,6 +834,7 @@ def enrich_items(
user_votes = (
get_user_votes(user["uid"], [item["uid"] for item in items]) if user else {}
)
maturity = get_maturity_by_targets(key, [item["uid"] for item in items])
enriched = []
for item in items:
entry = {
@@ -592,10 +842,38 @@ def enrich_items(
"author": authors.get(item["user_uid"]),
"time_ago": time_ago(item[ts_field]),
"my_vote": user_votes.get(item["uid"], 0),
"maturity": maturity.get(item["uid"], {}).get("level", "general"),
}
for name, source in extra_maps.items():
entry[name] = (
source(item) if callable(source) else source.get(item["uid"], 0)
)
if key == "post" and item.get("project_uid"):
entry["project_link"] = get_project_by_uid(item["project_uid"])
enriched.append(entry)
return enriched
def count_project_devlog(project_uid: str) -> int:
return get_table("posts").count(project_uid=project_uid, deleted_at=None)
def get_project_devlog(
project_uid: str, before: str | None = None, viewer: dict | None = None
) -> tuple[list, str | None]:
posts, next_cursor = paginate(
get_table("posts"),
before=before,
viewer_uid=viewer["uid"] if viewer else None,
project_uid=project_uid,
)
if not posts:
return [], None
authors = get_users_by_uids([post["user_uid"] for post in posts])
counts = get_comment_counts_by_post_uids([post["uid"] for post in posts])
enriched = enrich_items(
posts, "post", authors, {"comment_count": counts}, user=viewer
)
return enriched, next_cursor
+20 -1
View File
@@ -45,6 +45,12 @@ HTTP_VERSION_LABELS: dict[int, bytes] = {
}
def http_version_for(url: httpx.URL):
if url.scheme == "http":
return CurlHttpVersion.V1_1
return None
def resolve_timeout(request: httpx.Request) -> float:
extension = request.extensions.get("timeout") or {}
for key in ("read", "connect", "pool"):
@@ -67,10 +73,17 @@ class CurlResponseStream(httpx.AsyncByteStream):
class CurlTransport(httpx.AsyncBaseTransport):
def __init__(self, *, impersonate: str = IMPERSONATE_TARGET, verify: bool = True) -> None:
def __init__(
self,
*,
impersonate: str = IMPERSONATE_TARGET,
verify: bool = True,
proxy: str | None = None,
) -> None:
self._session = AsyncSession()
self._impersonate = impersonate
self._verify = verify
self._proxy = proxy
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
headers = {
@@ -79,6 +92,10 @@ class CurlTransport(httpx.AsyncBaseTransport):
if key.lower() not in STRIP_REQUEST_HEADERS
}
body = await request.aread()
extra = {}
version = http_version_for(request.url)
if version is not None:
extra["http_version"] = version
try:
response = await self._session.request(
request.method,
@@ -87,9 +104,11 @@ class CurlTransport(httpx.AsyncBaseTransport):
data=body or None,
impersonate=self._impersonate,
verify=self._verify,
proxy=self._proxy,
stream=True,
allow_redirects=False,
timeout=resolve_timeout(request),
**extra,
)
except Timeout as exc:
raise httpx.ConnectTimeout(str(exc), request=request) from exc
+12
View File
@@ -36,6 +36,18 @@ def owner_for(request: Request) -> tuple[str, str] | None:
def _overrides_for(request: Request) -> dict:
cached = getattr(request.state, "_custom_overrides", None)
if cached is not None:
return cached
overrides = _resolve_overrides(request)
try:
request.state._custom_overrides = overrides
except Exception:
pass
return overrides
def _resolve_overrides(request: Request) -> dict:
if get_setting("customization_enabled", "1") != "1":
return {"css": "", "js": ""}
owner = owner_for(request)
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
This file documents devplacepy/database/ - the dataset/SQLite data layer, indexing rules, and the project-wide soft-delete model. Claude Code loads it automatically whenever a file under this directory is read or edited.
## Database engine and dataset library
SQLite via `dataset` with these pragmas on every connection:
```python
PRAGMA journal_mode=WAL; -- concurrent readers + writers
PRAGMA synchronous=NORMAL; -- safe with WAL mode
PRAGMA busy_timeout=30000; -- wait 30s instead of failing on lock
PRAGMA cache_size=-8000; -- 8MB page cache
PRAGMA temp_store=MEMORY; -- temp tables in memory
```
Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}, "poolclass": NullPool}, on_connect_statements=[...])`. In addition to the pragmas above, the connection is tuned with a 30s busy timeout, an 8MB page cache, and a 256MB mmap.
**`poolclass=NullPool` is load-bearing - never revert it to SQLAlchemy's default `QueuePool` (caused a production outage).** `dataset.Database.executable` caches ONE DBAPI connection per OS thread ID **forever** and never returns it to the pool except via `db.close()`, which nothing in this codebase calls (`dataset/database.py`: `self.connections[tid] = self.engine.connect()`). That is fine as long as the same handful of threads ever touch the DB - but FastAPI runs every sync route dependency (`get_setting` and friends, hit on nearly every request) through `anyio.to_thread.run_sync`, whose worker pool scales up and recycles threads elastically under load, and container sync (`asyncio.to_thread`) adds more. Each new thread's first query permanently claims one pool slot. With the default bounded `QueuePool` (`pool_size=5, max_overflow=10` = 15 total), a burst of concurrent load creates enough new threads that the pool fills for good within minutes, and every request thereafter - including the Docker healthcheck's own probe - blocks the full 30s pool timeout and then raises `sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached`, wedging the whole app (nginx waits on an app that is waiting on itself; every external caller sees a bare connection timeout, not an HTTP error). `NullPool` removes the artificial ceiling: each `engine.connect()` opens a real, unpooled SQLite connection, so the existing "one connection cached per thread forever" behavior just works, exactly as WAL mode is designed to support. Never pass `pool_size`/`max_overflow` alongside `NullPool` (SQLAlchemy rejects them). Do not "fix" the underlying thread churn instead - that means touching the sync-dependency/threadpool model, which the hard rule below forbids.
`init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` (through the `_index()` helper, wrapped in try/except so it is safe to run on every startup regardless of table state), and seed defaults for `site_settings` only insert when the key doesn't already exist.
**`init_db()` also ensures the full column set of any table that code filters on** (`news`, `news_sync`, `attachments` use `get_table(name)` + `create_column_by_example` before their indexes). This is mandatory, not optional: `dataset` gives a lazily-created table ONLY the columns of its first insert, so a partial insert from a CLI tool/test/maintenance script would otherwise create the table with a reduced schema, and the long-running server caches that stale schema - making later queries on the missing column 500 with `no such column`. When adding a new filtered/indexed column, add it to the matching `init_db()` ensure-block:
```python
news = get_table("news")
for column, example in (("uid", ""), ("status", ""), ("synced_at", ""), ("external_id", ""), ...):
if not news.has_column(column):
news.create_column_by_example(column, example)
_index(db, "news", "idx_news_status", ["status"]) # now safe - column exists
```
Do this with `get_table(name)` (NOT `if name in db.tables`): `get_table` + `create_column_by_example` creates the table if it is absent, so the schema is guaranteed regardless of which process or which insert touches the table first. Symptom to watch for: a page renders fine in isolation but shows empty data or 500s only after a CLI/maintenance test runs earlier in the suite (it created the table partially).
**This applies to `users` too, and an `if "users" not in db.tables: return` guard silently defeats it.** `backfill_api_keys()` is the `users` ensure-block (every non-signup column: `api_key`, `last_seen`, the AI correction/modifier settings, `avatar_seed`, `award_count`/`last_award_at`/`last_award_slug`/`last_award_uid`, ...). It used to early-return when `users` was absent, which is exactly the state on a **brand-new database**: `init_db()` runs before the first signup, so the ensure-block was skipped entirely, and the first `/auth/signup` then created `users` with only the columns of that INSERT. Every ensured-but-unwritten column was therefore missing from the long-running server's reflected metadata, so `users.find_one(...)` returned rows without them **for the whole process lifetime** - the feature reading them looked simply switched off (this is what made the awards tab, the prominent-award banner, and the avatar award badge invisible on a fresh DB, and it is invisible in production only because the columns happen to exist from an older boot). It now calls `get_table("users")` unconditionally, so the table is born with the full ensured column set. Never reintroduce a `db.tables` guard in front of a column-ensure block.
The `_index(...)` helper supports `where=` (partial) and `unique=` indexes; every table with a `uid` column gets a UNIQUE `idx_<table>_uid`, soft-delete tables get a PARTIAL `idx_<table>_trash` (`WHERE deleted_at IS NOT NULL`) and NEVER a bare `deleted_at` index (it mis-steers the planner on live reads), and "live newest-first" listings need a composite/live-partial index that carries the sort column (see "Indexing conventions" below). `init_db()` finishes with `ANALYZE`/`PRAGMA optimize`. Verify any index change with `EXPLAIN QUERY PLAN`.
**SQLite is synchronous by design and will never be made async (hard rule).** The `dataset`/SQLite layer is synchronous and is called directly inside the async route handlers. This is intentional and more than fast enough for this platform: the database is a local file tuned with WAL, `synchronous=NORMAL`, a 30s busy timeout, an 8MB page cache, and a 256MB mmap, so queries are sub-millisecond and never meaningfully stall the event loop. Do NOT convert the database layer to async, do NOT wrap DB calls in a threadpool/`run_in_executor`/`to_thread`, and do NOT raise the synchronous DB access as a performance, blocking-I/O, or "requests aren't fully async" concern. This is a settled, sophisticated design choice and is not open for revisiting.
Runtime config lives in `site_settings`, read via `get_setting(key, default)` / `get_int_setting(key, default)` (60s TTL cache, invalidated cross-worker via the `cache_state` version table - `get_setting` calls `sync_local_cache("settings", ...)`, writes call `bump_cache_version("settings")`; the `_user_cache` in `utils.py` uses the same primitive under the `auth` name). Consumers always pass the production default to `get_setting`, so behavior is correct even before the row exists. Numeric operational values are floored at the call site so an invalid `0` can't lock out writes or stall a service. Booleans are stored as `"0"`/`"1"` and rendered as `<select>` (not checkboxes) because the settings save handler skips empty form values - an unchecked checkbox could never be turned off. See "Site settings" and "Operational settings" below for the full key registry.
Batch helpers (`get_users_by_uids`, `get_comment_counts_by_post_uids`, `get_vote_counts`, `load_comments`, `get_attachments_by_type`) exist specifically to avoid N+1 queries - use them in feed/listing routes instead of per-row lookups.
## Dataset rules (hard-learned)
**`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions.
```python
# WRONG - causes 500 Internal Server Error:
table.find("created_at >= :start", {"start": today})
table.find(text("created_at >= :start"), start=today)
# CORRECT - dict comparison syntax:
table.find(created_at={">=": today})
# CORRECT - keyword equality:
table.find(country="France")
# CORRECT - SQLAlchemy column expression for IN clause:
table.find(table.table.columns.user_uid.in_(["uid1", "uid2"]))
# CORRECT - multiple equality filters combined:
table.find(topic="devlog", user_uid=some_uid)
```
**`update()` requires a key column list as second argument.** The first dict contains all fields including the key column.
```python
table.update({"uid": user_uid, "bio": "new bio"}, ["uid"])
```
**`db.query()` accepts raw SQL with named params as keyword arguments:**
```python
db.query("SELECT * FROM posts WHERE topic = :t", t="devlog")
# NOT: db.query("...", {"t": "devlog"})
```
**`db.query()` WRITES DO NOT AUTO-COMMIT - wrap any `db.query` INSERT/UPDATE/DELETE in `with db:` (load-bearing, caused a production deadlock).** The dataset table API (`table.insert`/`update`/`delete`) calls `db._auto_commit()` internally, but `db.query()` does NOT. SQLAlchemy 2.x autobegins a transaction on first `execute`, so a raw `db.query` write leaves an open transaction holding the SQLite write lock until that thread's connection next commits. On the request/loop thread this is masked (the next table op's `_auto_commit` flushes it), but on a **background-queue or `run_in_executor` worker thread** the thread goes idle still holding the lock, and EVERY subsequent write app-wide blocks for the 30s busy-timeout then fails `database is locked` - a full deadlock. Always commit raw writes:
```python
with db: # commits + releases the write lock on exit
db.query("INSERT INTO t (...) VALUES (:a) ON CONFLICT(...) DO UPDATE SET ...", a=1)
```
Atomic counters (e.g. `add_correction_usage`) must use raw `ON CONFLICT DO UPDATE SET col = col + excluded.col` (the table API cannot increment), so they MUST use the `with db:` wrapper. Prefer the table API whenever an atomic SQL increment is not required.
**Always check `tables` list before raw SQL queries:**
```python
if "comments" not in db.tables:
return {} # table doesn't exist yet
```
**Batch queries eliminate N+1 problems.** Use `get_users_by_uids()`, `get_comment_counts_by_post_uids()`, and `get_vote_counts()` from `database.py` instead of per-row lookups in loops.
**`init_db()` MUST create every queried column for any table that code filters on, even if the table is created lazily.** dataset creates a table on its FIRST insert and gives it ONLY the columns in that insert. If any code path can insert a *partial* row before the full schema exists (a CLI tool, a test fixture, a maintenance script), the table is born with a reduced schema and every later query against a missing column throws `sqlite3.OperationalError: no such column: X` (a 500), or - for an indexed column - logs a `Could not create index ... no such column` warning at startup. This is worsened by **cross-process metadata staleness**: the long-running uvicorn server reflects a table's columns once and caches them, so a column another process adds afterward is invisible to the server until it reconnects. The defence is to make `init_db()` ensure the complete column set up front, exactly like the existing `news`, `news_sync`, and `attachments` blocks (see the code example under "Database engine and dataset library" above). When you add a NEW column that any query filters/indexes, add it to the `init_db()` ensure-block too - never rely on the first insert to define it.
## Indexing conventions (the soft-delete planner trap)
`init_db()` owns every index. The `_index(db, table, name, columns, *, where=None, unique=False)` helper builds the DDL; it supports **partial** indexes (`where=`) and **unique** indexes, and wraps each `CREATE`/`DROP` in `with db:` (DDL via `db.query` does not auto-commit - see "Dataset rules" above). Three load-bearing rules learned from an `EXPLAIN QUERY PLAN` audit against production data:
- **Every table with a `uid` column gets `idx_<table>_uid` (UNIQUE).** `dataset` makes its own `id` autoincrement PK and does NOT key `uid`, so `find_one(uid=...)`, `resolve_by_slug`, `soft_delete`, and `table.update({...}, ["uid"])` full-SCAN without it. `init_db()` loops `for table in db.tables: _uid_index(db, table)` (falls back to a non-unique index if a UNIQUE build ever fails on legacy duplicate data). New tables are covered automatically.
- **NEVER index the bare `deleted_at` column - use a PARTIAL trash index `WHERE deleted_at IS NOT NULL`.** `ensure_soft_delete_columns` creates `idx_<table>_trash ON (deleted_at) WHERE deleted_at IS NOT NULL` (and drops any legacy full `idx_<table>_deleted`). A full `deleted_at` index is a planner hazard: the column is one giant `NULL` bucket plus many unique delete-timestamps, so `sqlite_stat1` mis-estimates `deleted_at IS NULL` as returning ~2 rows and the planner picks that index for live reads, then `USE TEMP B-TREE FOR ORDER BY` to sort the whole live set (the global feed was doing exactly this, with 82% of posts soft-deleted). The partial index serves the admin Trash view (`deleted_at IS NOT NULL`) cheaply and stops poisoning live `IS NULL` queries.
- **For "live, newest-first" listings add a composite or live-partial index that includes the sort column.** A `WHERE deleted_at IS NULL ORDER BY created_at` query needs the ordering in the index or it filesorts. Posts use a partial `idx_posts_live_created ON (created_at) WHERE deleted_at IS NULL` (feed) plus `idx_posts_user_created (user_uid, created_at)` (profile); comments use `idx_comments_target_created (target_type, target_uid, created_at)`; votes use `idx_votes_user_target (user_uid, target_uid)` (the per-user "my_vote" check on every card); notifications/gists/projects use `(user_uid, created_at)`; follows use `idx_follows_follower_created (follower_uid, created_at)` + `idx_follows_following_created (following_uid, created_at)` (the followers/following tabs sort newest-first; the legacy single-column follower/following indexes were dropped as redundant prefixes). All were verified to drop the `USE TEMP B-TREE FOR ORDER BY` step.
- **Index the non-`uid` lookup keys too, not just the sort/owner columns.** A demand-vs-supply audit added the last missing single-key lookups: the `resolve_by_slug` hot path filters `slug` on content detail pages, so posts/gists/news/projects each get `idx_<table>_slug (slug)`; `get_setting`/`set_setting` filter `key`, so `idx_site_settings_key (key)`; the container store's `find_one(slug=)`/`find_one(name=)` fallbacks get `idx_instances_slug`/`idx_instances_name`. The DM thread load `find(sender_uid=, receiver_uid=)` gets the covering composites `idx_messages_conversation (sender_uid, receiver_uid)` + `idx_messages_conversation_rev (receiver_uid, sender_uid)` (the read-flag `UPDATE` uses the reverse); the badge-has check gets `idx_badges_user_name (user_uid, badge_name)`; the admin user list `ORDER BY -created_at` gets `idx_users_created_at (created_at)` (the existing `(role, created_at)` cannot serve a full-table created_at sort). All are non-unique so `_index` always creates them even if legacy duplicate data exists. Column sets already resolved to ~1 row by an existing prefix index (votes `+target_type`, game_quests `+kind`, poll_options `position`) are intentionally left uncovered - a trailing column there only adds write cost.
`init_db()` ends with `_refresh_query_planner_stats()`: a one-time `ANALYZE` when `sqlite_stat1` is absent, then `PRAGMA optimize` every boot (both inside `with db:`). Validate any index change with `EXPLAIN QUERY PLAN` against `data/devplace.db` (or a copy) - a correct plan reads `SEARCH ... USING INDEX <name>` with no temp b-tree.
## Startup backfills must converge (hard rule)
`init_db()` runs inside `lifespan` under an exclusive `init_lock()`, **before the worker accepts a single request**, and every uvicorn worker runs it in turn. Time-to-serving is therefore `workers x init_db`, so anything added there is paid N times on every boot and every deploy. Two rules follow:
- **A backfill must be able to finish.** A backfill selects the rows that still need migrating and must leave them *not* selected afterwards. If a row can stay in the candidate set after a successful pass, the "one-time migration" is really a permanent per-boot scan that grows with the table. `_backfill_gamification` had exactly this bug: it selected `users.find(xp=0)` and then ran the full milestone sweep over *every* one of them, but a user with no content is awarded no XP, so they stayed at `xp=0` and were re-swept forever. At 7814 such users that was 36s of the 37.5s boot - about 140k queries that provably could not award anything, on every worker, on every restart.
- **Never fan a per-row query out over a whole table at boot.** Compute the candidate set with a few set-based `GROUP BY`/`DISTINCT` queries first, then do per-row work only for rows that survive. `_milestone_candidates()` is the pattern: one `SELECT DISTINCT` per milestone source table (`MILESTONE_SOURCES`), unioned into a set, intersected with the pending users. A user absent from all of those tables scores 0 on every milestone metric and the lowest threshold is 5, so skipping them cannot change any award - verified by diffing the full `badges` table between the full sweep and the narrowed one (identical, 9800 rows, 22.6s -> 0.9s).
Watch for the same shape in `dataset` internals: `db.tables` is a live SQLAlchemy reflection, not a cached attribute. `get_user_stars` does one `in db.tables` check per `STAR_TARGETS` entry, so a per-user loop calling it re-reflects the whole table list on every iteration - 78667 reflections costing 17.7s in the profile above. Hoist `db.tables` into a local when looping.
Profile with the real database before and after any change here (`cProfile` around `init_db()` against a copy of `data/devplace.db`); a synthetic or empty DB hides every one of these costs.
## Project-wide soft delete (hard rule)
Attachments (see "Profile media gallery and soft-deleted attachments" below) are one instance of a platform-wide model: **every removal is a soft delete; only garbage collection is a hard delete.** Every removable row carries two columns, `deleted_at` (ISO timestamp) and `deleted_by` (actor uid, or `system`). A live row has `deleted_at = NULL`, and every list/count read filters `deleted_at IS NULL`.
- **Tables (`database.SOFT_DELETE_TABLES`):** posts, comments, gists, projects, news, news_images, project_files, attachments, votes, reactions, bookmarks, follows, poll_votes, polls, poll_options, sessions, instances, instance_schedules, devii_conversations, devii_tasks, devii_lessons, devii_virtual_tools, user_customizations, project_forks. `init_db` loops `ensure_soft_delete_columns(t)` for each (adds both columns + `idx_<t>_deleted` / the partial trash index described above). The Devii task/lesson/virtual-tool stores run on their own DB handles, so their `_ensure_indexes` adds the columns there too.
- **Every INSERT into a soft-deletable table MUST write `deleted_at: None, deleted_by: None`.** `dataset.find(deleted_at=None)` on a table that lacks the column matches NOTHING (a false predicate), silently hiding all rows - the born-live insert is what creates the column. Add the pair to any new insert.
- **Central helpers (`database/`):** `soft_delete(table, deleted_by, *, stamp=None, **criteria)` (equality), `soft_delete_in(table, column, uids, deleted_by, *, stamp=None, **extra)` (IN-clause cascade), `restore(table, **criteria)`, `purge(table, **criteria)` (real delete), `list_deleted(table, page)` / `count_deleted(table)` (trash listings), and the event helpers `restore_event(stamp)` / `purge_event(stamp)` that act across ALL tables sharing one `deleted_at` stamp.
- **Two generic chokepoints are conditionally filtered:** `resolve_by_slug(table, slug, include_deleted=False)` (detail-page lookups; restore passes `include_deleted=True`) and `paginate(table, ...)` (auto-appends `deleted_at IS NULL` when the table has the column and the caller did not pass `deleted_at`). `seo._collect` does the same for the sitemap. Read filters were threaded through every batch helper, analytics/activity/leaderboard UNION, feed/profile/listing route, and store; never re-introduce an unfiltered read of a soft-deletable table.
- **Any new read** (find/count/query) of a soft-deletable table MUST filter `deleted_at IS NULL`. Use the central helpers/chokepoints instead of inline deletes.
- **Toggles revive, they do not duplicate.** votes/reactions/bookmarks/follows/poll_votes look up the physical row regardless of `deleted_at`: toggle-off stamps `deleted_at`; re-toggle clears it on the same row. Counts/state reads filter `deleted_at IS NULL`.
- **Cascades share one stamp.** `content.delete_content_item` soft-deletes the item plus its comments, votes, engagement, project files, fork relations, and attachments with one shared `stamp` and `deleted_by = actor`. That timestamp identifies the whole event, so `restore_event`/`purge_event` reverse or finalize it atomically.
- **Delete authorization is owner-OR-admin, enforced on the endpoint** (`is_owner(...) or is_admin(user)`): posts/gists/projects (`content.delete_content_item`, also rejects a missing item), `comments.delete_comment`, `project_files.project_file_delete`, `media.delete_media`, `uploads.delete_attachment_route`; news (`admin_news_delete`) is admin-only. Because the check is on the endpoint, one rule covers the human UI and **Devii** at once - Devii only ever calls the platform API, authenticated as the signed-in user, so an admin's Devii may soft-delete any member's content and a member's is refused with no agent-side logic. The matching `delete_*` Devii catalog tools stay `requires_auth` (not `requires_admin`) so a member can still delete their own, and every one is in the dispatcher's confirmation gate (`CONFIRM_REQUIRED`) so a delete only runs on a repeat call with `confirm=true`. Standalone `comment` and uploaded-`attachment` deletes are soft like the rest (`soft_delete` cascade / `soft_delete_attachment`); the only attachment hard delete is the admin `/admin/media/{uid}/purge` and the CLI prune. Any NEW content delete path must reuse this guard, soft-delete, and (for the Devii tool) be added to `dispatcher.CONFIRM_REQUIRED`.
- **What stays HARD (GC / the empty-trash stage):** the async-job sweep + CLI prune/clear, the container metrics ring trim, gateway and Devii usage-ledger retention prunes and quota resets, the expired-session cleanup branch in `utils._user_from_session`, fork-rollback of a half-created project, the news-sync image replacement, and the admin **Purge** action. Logout is a soft delete (auditable via `deleted_by`); only expiry GC is hard.
- **Admin Trash surface:** `/admin/trash` (sidebar **Trash**, `routers/admin/` package, `admin_trash.html`, `AdminTrashOut`) lists soft-deleted rows per table with restore/purge per row. Restore calls `restore_event(row.deleted_at)`; Purge calls `purge_event(...)` and unlinks attachment files / project-file blobs. The attachment-specific `/admin/media` view is unchanged. Admin-only docs: `docs/soft-delete.html` (`admin: True`, Administration section).
## Profile media gallery and soft-deleted attachments
The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginated grid of every attachment a user uploaded, newest first, across all `target_type`s. Attachments support a **soft delete** so a user can remove one upload without affecting its parent object.
- **One `deleted_at` column** on `attachments` (ISO string, mirrors the `push.py` precedent), ensured idempotently in `init_db` via `create_column_by_example("deleted_at", "")` plus the `idx_attachments_user_created` index. `store_attachment` writes `deleted_at: None`.
- **Soft delete preserves the relation and the file.** `attachments.soft_delete_attachment(uid)` only stamps `deleted_at`; it never touches `target_type`/`target_uid` and never unlinks the file. `restore_attachment(uid)` clears `deleted_at`, so the item reappears on its parent object and in the gallery with zero extra bookkeeping.
- **Three read paths filter `deleted_at IS NULL`** so a soft-deleted item vanishes everywhere (the gallery AND its parent post/project/etc.): `get_attachments`, `get_attachments_batch` (both in `attachments.py`), and `database.get_user_media`. **The hard-delete cascades (`delete_attachments_for`, `delete_target_attachments`) stay UNFILTERED** so permanently deleting a parent object still removes ALL its attachment files, including soft-deleted ones - never add the filter there.
- **Queries:** `database.get_user_media(user_uid, page)` (linked, non-deleted, newest first; each item gets a `target_url` via `resolve_object_url`) and `database.get_deleted_media(page)` (the admin trash, joined to uploader username).
- **Authorization:** `POST /media/{uid}/delete` (`routers/media.py`) is owner-or-admin (`attachment["user_uid"] == user["uid"] or is_admin(user)`); `POST /media/{uid}/restore` and `POST /admin/media/{uid}/purge` (the only hard delete, via `delete_attachment`) are admin-only (`routers/admin/` package, sidebar **Media** -> `/admin/media`). The tab itself is public.
- **Frontend:** `_media_gallery.html` reuses the `_attachment_display.html` type branches and the `dp-lightbox` contract (`data-lightbox`/`data-full`). The delete button carries `data-media-delete` + `data-confirm`; `ModalManager.initConfirmations` shows the confirm and `MediaGallery.js` (`app.mediaGallery`) does the optimistic `Http.send` delete, fades the tile, and toasts. A `<noscript>` form is the no-JS fallback. Grid styling is `static/css/media.css`.
- **Devii:** `list_media` (public) and `delete_media` (auth, in `CONFIRM_REQUIRED`) in the catalog.
- **Docs visibility (deliberate):** members and guests must never be told this is a *soft* delete. The public prose page `docs/media-gallery.html` (General) and the member-facing `media-delete` API endpoint (Profiles group) describe deletion as a plain "remove" - no soft-delete, restore, trash, or purge language. All moderation mechanics live on the admin-only `docs/media-moderation` prose page (`admin: True`) and in the admin API group (`media-restore`, `admin-media`, `admin-media-purge`, all `auth="admin"`), which `docs_search` excludes from member results and `routers/docs/` package 404s for non-admins. Because `docs_search._strip` keeps the text *inside* `{% if %}` blocks, admin content must live on a separate `admin: True` page, never inline-gated on a public page (a public page may only carry an admin-gated *link*). The member `MediaItemOut` schema omits `deleted_at`; the admin-only `AdminMediaItemOut` adds it.
## Moderation, consent and maturity tables (`database/moderation.py`)
Four soft-deletable tables carry the trust-and-safety layer; the full subsystem is documented in `devplacepy/services/moderation/CLAUDE.md`.
| Table | Shape | Notes |
|---|---|---|
| `content_reports` | `(target_type, target_uid)` + `reporter_uid` + `owner_uid` | The one queue. `owner_uid` is denormalised at insert so the admin list never N+1s. Indexes `(status, created_at)` for the queue and SLA scan, `(target_type, target_uid)` for duplicate detection, `(reporter_uid)`, `(owner_uid)` |
| `moderation_actions` | one row per moderator decision, linked to its report | Queryable moderation state with its own lifecycle - deliberately separate from the append-only audit log, the same way `workspace_flags` is |
| `content_maturity` | `(target_type, target_uid)` -> `level` | Polymorphic age label. Read through the batch helper `get_maturity_by_targets`, never per row. **Absence of a row means `general`**, so nothing needed backfilling |
| `user_consents` | `(owner_kind, owner_id, kind)` | Append-only in effect: withdrawing stamps `withdrawn_at` on the current row and inserts a new one, so the history is provable |
`REPORTABLE_TARGETS` (target type -> table) is the registry every consumer reads, exactly like `VOTABLE_TARGETS`. `UNREPORTABLE_TABLES` is its explicit counterpart: each entry names a soft-deletable table and **why** it carries no reportable content. `tests/unit/database/moderation.py` asserts the two partition `SOFT_DELETE_TABLES`, so a new user-generated table cannot be added without classifying it.
The `users` columns added alongside are ensured in `backfill_api_keys()` like every other non-signup column: `terms_version`, `terms_accepted_at`, `age_band`, `age_declared_at`, `mature_opt_in`, `suspended_until`, `suspension_reason`, `deletion_requested_at`. `mature_opt_in` is normalised from NULL to `0` in the same `with db:` block that fixes the AI-modifier defaults, because it is read as a flag. **No date of birth is ever stored** - only the derived `age_band`.
Two atomic conditional updates protect this data and must never become read-then-write: `queue.claim_open` (report resolution) and `deletion.claim_deletion` (the account-deletion cascade). The latter's precondition is `COALESCE(deletion_requested_at, '') = ''` because the column is SQL `NULL` on rows that predate it - the exact `NULL = 0` trap recorded above.
## Role-based visibility (generic + DRY)
- **One source of truth for role/visibility checks**, registered as Jinja globals in `templating.py` - never hand-roll `user.get('role') == 'Admin'` or `user['uid'] == x['user_uid']` in a template again:
- `is_admin(user)` (also `utils.is_admin`, reused by `require_admin` and `docs.py`) - admin-only UI.
- `owns(item, user)` (= `content.is_owner`) - per-item ownership (e.g. each comment). Page-level detail templates keep using the `is_owner` **bool** passed in their context (post/gist/project/profile); do not call `is_owner(...)` as a function - that name is a context bool and shadows globals.
- `is_self(user, uid)` - "is this me" (profile follow vs edit, leaderboard highlight).
- `guest_disabled(user)` -> emits ` disabled aria-disabled="true" title="Log in to participate"` for guests (empty for members); `login_hint(user)` -> a small login link. Both return `Markup`.
- **Account enabled/disabled is `database.is_account_active(row)` - never read `is_active` inline.** `is_active` is a nullable column, so a row written before it existed (or by any insert that omits it) holds SQL `NULL`, and the obvious `bool(row.get("is_active"))` reads that as *disabled*. `.get("is_active", True)` is no better: the default only applies when the key is **absent**, and a `SELECT *` row always has the key with value `None`. The predicate is `is_active is None or bool(is_active)` - unknown means active, only an explicit `0`/`False` disables. This is the same NULL-vs-0 trap as the `COALESCE` rule for atomic updates. It gates session auth, API-key auth, Basic auth, the login and access-token routes, the devRant token/auth paths, the admin enable/disable toggle, and `_can_hold_primary_admin`; every one of them goes through this single function. A NULL row previously could not log in at all, and the admin toggle could not disable it. (The `is_active` on a `gateway_models` row is a different table with its own semantics and is deliberately not routed through this.)
- **Role values are stored capitalized:** `users.role` is exactly `"Admin"` or `"Member"` (first registered user is `"Admin"`, `auth.py`); `is_admin` compares `== "Admin"` case-sensitively. The CLI is the only lowercase surface (`devplace role set ... <member|admin>` writes `role.capitalize()`; `role get` prints `.lower()`). A lowercase role in the DB silently defeats every admin check - never write a raw lowercase role.
- **The shadow rule generalizes beyond `is_owner` to ANY Jinja global** (`is_admin`, `avatar_url`, `format_date`, `is_self`, `owns`, `guest_disabled`): `respond(req, tmpl, ctx, model=XOut)` hands the **same** `ctx` to the Pydantic model and the template, and a context key shadows the same-named global across the whole `base.html` chain. A bool named `is_admin` in the context makes `base.html`'s `{% if is_admin(user) %}` raise `TypeError: 'bool' object is not callable` - a 500 that only fires for the branch invoking the global (logged-in users, not guests, which is why guest-only smoke tests miss it). Name viewer/permission flags distinctly (`viewer_is_admin`) in both schema and context. Real issue fixed on `/issues/{number}`; regression-guarded by `tests/api/issues/create.py::test_issue_detail_renders_for_{member,admin}` (they render the page as an authenticated Member/Admin and assert 200 + the admin-only control).
- **Policy enforced everywhere:** guests see all non-admin content read-only with action controls **shown but disabled** (`guest_disabled` on vote/star/react/poll/bookmark/follow/comment submit; create FABs become `/auth/login` links via `.feed-fab.login-required`); members get full member actions; **role badges render only to admin viewers** (`{% if is_admin(user) %}` around every `*.role` label). Backend stays the real gate (`require_user`/`require_admin`).
- Docs admin gating is unchanged behaviourally but now uses `is_admin` (`docs_base.html` `DEVPLACE_DOCS.isAdmin`, `docs/index.html`, `docs.py`).
- **Tests:** the role-gating e2e tests across `tests/e2e/` (guest/member/admin via `page`/`bob`/`alice`) are the UI enforcement; `tests/api/auth/matrix.py` is the backend companion. Guest action controls are asserted **disabled** (not absent) - don't reintroduce `count() == 0` assertions for them.
## Database tables
| Table | Purpose |
|-------|---------|
| `news` | All synced articles with `status` (published/draft), `grade`, `slug`, `show_on_landing` |
| `news_images` | Images extracted from article URLs |
| `news_sync` | Sync state per article `guid` - tracks grading history |
The platform-wide soft-delete table set (`database.SOFT_DELETE_TABLES`) is listed in full under "Project-wide soft delete (hard rule)" above.
## Site settings
Site settings are seeded on startup (`site_settings` table):
| Key | Default | Purpose |
|-----|---------|---------|
| `site_name` / `site_description` / `site_tagline` | DevPlace branding | General site metadata |
| `news_grade_threshold` | `"7"` | Minimum AI grade for auto-publish |
| `news_api_url` | `"https://news.app.molodetz.nl/api"` | News source API |
| `news_ai_url` | `"https://openai.app.molodetz.nl/v1/chat/completions"` | AI grading endpoint |
| `news_ai_model` | `"molodetz"` | AI model identifier |
| `max_upload_size_mb` / `allowed_file_types` / `max_attachments_per_resource` | `"10"` / `""` / `"10"` | Upload limits |
| `rate_limit_per_minute` | `"60"` | Mutating requests per IP per window (`main.py` middleware); a `429` carries a `Retry-After: <window>` header |
| `rate_limit_window_seconds` | `"60"` | Rolling window for the rate limit (also the `Retry-After` value) |
| `news_service_interval` | `"3600"` | Seconds between news fetch cycles (`NewsService.run_once` re-reads each cycle) |
| `session_max_age_days` | `"7"` | Standard session cookie + DB session lifetime |
| `session_remember_days` | `"30"` | Remember-me session lifetime |
| `registration_open` | `"1"` | When `"0"`, signup GET shows a closed notice and POST is rejected (`auth.py`) |
| `maintenance_mode` | `"0"` | When `"1"`, non-admins get a 503 (`main.py` maintenance middleware) |
| `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page |
| `customization_enabled` | `"1"` | When `"0"`, `custom_css_tag`/`custom_js_tag` inject nothing (feature off) |
| `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed |
| `moderation_sla_hours` | `"24"` | The published moderation response window; the admin queue badge turns red past it |
| `moderation_filter_mode` | `"review"` | `off`/`label`/`review`/`block` - how the content filter acts on a match |
| `moderation_filter_review_score` | `"2"` | Rule score at which a match becomes a report rather than a label |
| `moderation_minimum_age` | `"16"` | Signup floor; only the derived age band is stored |
| `moderation_mature_default_hidden` | `"1"` | Hide mature-labelled content behind an interstitial by default |
| `account_deletion_grace_hours` | `"24"` | Reversible window before a deleted account is purged |
| `contact_email` / `contact_phone` / `contact_address` | `""` | Published contact details, rendered on `/docs/contact.html` |
| `terms_version` / `privacy_version` / `guidelines_version` | `"1"` | Bumping `terms_version` forces re-acceptance before the next write. **Every reader uses `get_setting(key, "1") or "1"`** - an empty stored value must read as the default or the gate 403s every write |
| `ai_third_party_provider` | `""` | Named in the consent copy and the privacy policy |
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
## Operational settings
Operational settings - read sites and rules:
| Setting(s) | Read at | Notes |
|-----------|---------|-------|
| `rate_limit_*` | `rate_limit_middleware` in `main.py` | `max(1, get_int_setting(...))` so `0` can't block all writes |
| `maintenance_mode` / `maintenance_message` | `maintenance_middleware` in `main.py` | Allows `/static`, `/avatar`, `/auth`, `/admin` and admins; everyone else gets `error.html` at 503 |
| `news_service_interval` | `BaseService` reconciling loop via `current_interval()` | `max(60, ...)`; edited on the Services tab (not `/admin/settings`); a change applies on the next cycle |
| `service_<name>_enabled` / `service_<name>_command` / `service_<name>_log_size` | `BaseService` reconciling loop | Generic per-service controls written by the Services tab; the loop reconciles within ~1s |
| `session_max_age_days` / `session_remember_days` | `auth.py` signup + login | Multiplied by `SECONDS_PER_DAY`; passed to `create_session(uid, max_age)` so the cookie and the DB session row expire together |
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off - `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.
+330
View File
@@ -0,0 +1,330 @@
# retoor <retoor@molodetz.nl>
from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta, timezone, TTLCache, DATABASE_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, ensure_data_dirs, logger, db
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
from .atomic import conditional_update_row
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, is_account_active, search_users_by_username
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage
from .awards import (
AWARDS_PER_PAGE,
award_display_hours,
award_give_cooldown_hours,
award_receive_cooldown_hours,
award_is_prominent,
can_give_award,
can_receive_award,
count_published_awards,
enrich_award,
get_prominent_award,
get_user_awards,
has_giver_cooldown,
has_receiver_cooldown,
recompute_user_award_stats,
revoke_award,
)
from .seo_meta import SEO_META_TYPES, get_seo_metadata, get_seo_metadata_batch, has_fresh_seo_metadata, upsert_seo_metadata, mark_seo_metadata_stale
from .activity import record_activity, record_unique_activity, get_user_activity, _activity_cache, _ACTIVITY_TABLES, get_activity_calendar, _activity_level, get_first_activity_date, HEATMAP_WEEKS, get_activity_heatmap, get_activity_months, get_streaks
from .customization import CUSTOMIZATION_GLOBAL_SCOPE, CUSTOMIZATION_LANGS, _customizations_cache, _customization_key, CUSTOMIZATION_PREF_COLUMNS, get_customization_prefs, set_customization_pref, get_custom_overrides, get_custom_override, list_custom_overrides, set_custom_override, delete_custom_override
from .email import EMAIL_ACCOUNT_DEFAULTS, list_email_accounts, get_email_account, set_email_account, delete_email_account
from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICATION_CHANNEL_COLUMNS, _NOTIFICATION_CHANNEL_DEFAULTS, _NOTIFICATION_TYPE_KEYS, _notification_prefs_cache, _notification_default, get_notification_default, set_notification_default, _notification_overrides, notification_enabled, get_notification_prefs, set_notification_pref, reset_notification_prefs, mark_notifications_read_by_target
from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_relations, delete_fork_relations
from .follows import get_follow_counts, get_follow_list, get_following_among
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
from .moderation import (
ACTIONS_TABLE,
ADULT_AGE,
AGE_BANDS,
CONSENTS_TABLE,
CONSENT_KINDS,
CONSENT_STATES,
MATURITY_LEVELS,
MATURITY_SOURCES,
MATURITY_TABLE,
MATURITY_TARGETS,
MODERATION_ACTIONS,
MODERATION_TABLES,
REPORTABLE_TARGETS,
REPORTS_TABLE,
REPORT_OPEN_STATUSES,
REPORT_ORIGINS,
REPORT_REASONS,
REPORT_SEVERITIES,
REPORT_STATUSES,
SYSTEM_ACTOR,
UNREPORTABLE_TABLES,
age_band_for,
band_allows_mature,
band_allows_restricted,
consent_granted,
consent_state,
get_maturity,
get_maturity_by_targets,
list_consents,
minimum_age,
report_reason_options,
set_consent,
set_maturity,
suspension_active,
years_between,
)
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
__all__ = [
"dataset",
"logging",
"Path",
"or_",
"defaultdict",
"datetime",
"timedelta",
"timezone",
"TTLCache",
"DATABASE_URL",
"DEFAULT_CORRECTION_PROMPT",
"DEFAULT_MODIFIER_PROMPT",
"INTERNAL_GATEWAY_URL",
"ensure_data_dirs",
"logger",
"db",
"refresh_snapshot",
"_local_cache_versions",
"_cache_version_cache",
"_cache_state_ready",
"_ensure_cache_state",
"get_cache_version",
"bump_cache_version",
"sync_local_cache",
"_index",
"_drop_index",
"_uid_index",
"get_table",
"_in_clause",
"_now_iso",
"conditional_update_row",
"_settings_cache",
"get_setting",
"get_int_setting",
"set_setting",
"clear_settings_cache",
"internal_gateway_key",
"get_users_by_uids",
"_admins_cache",
"invalidate_admins_cache",
"get_admin_uids",
"set_user_timezone",
"set_last_seen",
"get_online_users",
"get_primary_admin_uid",
"is_account_active",
"search_users_by_username",
"_relations_cache",
"get_user_relations",
"get_blocked_uids",
"get_muted_uids",
"get_silenced_uids",
"invalidate_user_relations",
"PAGE_SIZE",
"paginate",
"interleave_by_author",
"paginate_diverse",
"get_user_post_count",
"clear_user_post_count",
"build_pagination",
"SOFT_DELETE_TABLES",
"ensure_soft_delete_columns",
"soft_delete",
"soft_delete_in",
"restore",
"purge",
"list_deleted",
"count_deleted",
"restore_event",
"purge_event",
"_comment_count_cache",
"get_comment_counts_by_post_uids",
"get_post_counts_by_user_uids",
"get_vote_counts",
"get_user_votes",
"get_reactions_by_targets",
"get_user_bookmarks",
"get_polls_by_post_uids",
"get_poll_for_post",
"_add_usage",
"_get_usage",
"add_correction_usage",
"get_correction_usage",
"add_modifier_usage",
"get_modifier_usage",
"NEWS_USAGE_KEY",
"add_news_usage",
"get_news_usage",
"ISSUE_USAGE_KEY",
"add_issue_usage",
"get_issue_usage",
"SEO_USAGE_KEY",
"add_seo_usage",
"get_seo_usage",
"SEO_META_TYPES",
"get_seo_metadata",
"get_seo_metadata_batch",
"has_fresh_seo_metadata",
"upsert_seo_metadata",
"mark_seo_metadata_stale",
"record_activity",
"record_unique_activity",
"get_user_activity",
"_activity_cache",
"_ACTIVITY_TABLES",
"get_activity_calendar",
"_activity_level",
"get_first_activity_date",
"HEATMAP_WEEKS",
"get_activity_heatmap",
"get_activity_months",
"get_streaks",
"CUSTOMIZATION_GLOBAL_SCOPE",
"CUSTOMIZATION_LANGS",
"_customizations_cache",
"_customization_key",
"CUSTOMIZATION_PREF_COLUMNS",
"get_customization_prefs",
"set_customization_pref",
"get_custom_overrides",
"get_custom_override",
"list_custom_overrides",
"set_custom_override",
"delete_custom_override",
"EMAIL_ACCOUNT_DEFAULTS",
"list_email_accounts",
"get_email_account",
"set_email_account",
"delete_email_account",
"NOTIFICATION_TYPES",
"NOTIFICATION_CHANNELS",
"_NOTIFICATION_CHANNEL_COLUMNS",
"_NOTIFICATION_CHANNEL_DEFAULTS",
"_NOTIFICATION_TYPE_KEYS",
"_notification_prefs_cache",
"_notification_default",
"get_notification_default",
"set_notification_default",
"_notification_overrides",
"notification_enabled",
"get_notification_prefs",
"set_notification_pref",
"reset_notification_prefs",
"mark_notifications_read_by_target",
"record_fork",
"get_fork_parent",
"count_forks",
"soft_delete_fork_relations",
"delete_fork_relations",
"get_follow_counts",
"get_follow_list",
"get_following_among",
"_ds_now",
"create_deepsearch_session",
"update_deepsearch_session",
"get_deepsearch_session",
"add_deepsearch_message",
"get_deepsearch_messages",
"get_cached_deepsearch_url",
"upsert_deepsearch_url_cache",
"VOTABLE_TARGETS",
"STAR_TARGETS",
"_authors_cache",
"_ranked_authors",
"_rank_map",
"get_top_authors",
"get_leaderboard",
"get_user_rank",
"get_user_stars",
"clear_user_stars",
"update_target_stars",
"soft_delete_engagement",
"delete_engagement",
"get_target_owner_uid",
"ACTIONS_TABLE",
"ADULT_AGE",
"AGE_BANDS",
"CONSENTS_TABLE",
"CONSENT_KINDS",
"CONSENT_STATES",
"MATURITY_LEVELS",
"MATURITY_SOURCES",
"MATURITY_TABLE",
"MATURITY_TARGETS",
"MODERATION_ACTIONS",
"MODERATION_TABLES",
"REPORTABLE_TARGETS",
"REPORTS_TABLE",
"REPORT_OPEN_STATUSES",
"REPORT_ORIGINS",
"REPORT_REASONS",
"REPORT_SEVERITIES",
"REPORT_STATUSES",
"SYSTEM_ACTOR",
"UNREPORTABLE_TABLES",
"age_band_for",
"band_allows_mature",
"band_allows_restricted",
"consent_granted",
"consent_state",
"get_maturity",
"get_maturity_by_targets",
"list_consents",
"report_reason_options",
"set_consent",
"set_maturity",
"suspension_active",
"years_between",
"_drop_blocked",
"_build_comment_items",
"load_comments",
"get_recent_comments_by_target_uids",
"get_recent_comments_by_post_uids",
"load_comments_by_target_uids",
"resolve_by_slug",
"resolve_object_url",
"get_uids_by_username_match",
"text_search_clause",
"get_daily_topic",
"get_featured_news",
"get_trending_topics",
"get_attachments",
"get_attachments_by_type",
"get_news_images_by_uids",
"delete_attachment_record",
"delete_attachments",
"_delete_attachment_file",
"get_user_media",
"get_user_attachments",
"get_user_attachment",
"get_deleted_media",
"_stats_cache",
"get_site_stats",
"_analytics_cache",
"get_platform_analytics",
"_gist_languages_cache",
"get_gist_languages",
"BUG_TABLE_RENAMES",
"migrate_bug_tables_to_issue_tables",
"init_db",
"_refresh_query_planner_stats",
"OLD_GATEWAY_URL",
"migrate_ai_gateway_settings",
"backfill_api_keys",
"_backfill_gamification",
]
+182
View File
@@ -0,0 +1,182 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, datetime, db, timedelta, timezone
def record_activity(user_uid: str, action: str) -> int:
if not user_uid or not action or "user_activity" not in db.tables:
return 0
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT INTO user_activity (user_uid, action, count, first_at, last_at) "
"VALUES (:u, :a, 1, :now, :now) "
"ON CONFLICT(user_uid, action) DO UPDATE SET "
"count = count + 1, last_at = excluded.last_at",
u=user_uid,
a=action,
now=now,
)
rows = list(
db.query(
"SELECT count FROM user_activity WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["count"]) if rows else 0
def record_unique_activity(user_uid: str, action: str, target: str) -> int | None:
if not user_uid or not action or "user_activity_seen" not in db.tables:
return None
now = datetime.now(timezone.utc).isoformat()
with db:
db.query(
"INSERT OR IGNORE INTO user_activity_seen "
"(user_uid, action, target, created_at) VALUES (:u, :a, :t, :now)",
u=user_uid,
a=action,
t=str(target),
now=now,
)
changed = list(db.query("SELECT changes() AS c"))
if not changed or not changed[0]["c"]:
return None
rows = list(
db.query(
"SELECT COUNT(*) AS c FROM user_activity_seen "
"WHERE user_uid = :u AND action = :a",
u=user_uid,
a=action,
)
)
return int(rows[0]["c"]) if rows else 0
def get_user_activity(user_uid: str) -> dict:
if not user_uid or "user_activity" not in db.tables:
return {}
rows = db.query(
"SELECT action, count FROM user_activity WHERE user_uid = :u",
u=user_uid,
)
return {row["action"]: int(row["count"]) for row in rows}
_activity_cache = TTLCache(ttl=300, max_size=1000)
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
def get_activity_calendar(user_uid: str) -> dict:
cached = _activity_cache.get(user_uid)
if cached is not None:
return cached
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
calendar: dict[str, int] = {}
if sources:
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
union = " UNION ALL ".join(
f"SELECT created_at FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
for table in sources
)
rows = db.query(
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
u=user_uid,
cutoff=cutoff,
)
for row in rows:
if row["day"]:
calendar[row["day"]] = row["c"]
_activity_cache.set(user_uid, calendar)
return calendar
def _activity_level(count: int) -> int:
if count <= 0:
return 0
if count == 1:
return 1
if count <= 3:
return 2
if count <= 6:
return 3
return 4
def get_first_activity_date(user_uid: str):
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
if not sources:
return None
union = " UNION ALL ".join(
f"SELECT MIN(created_at) AS m FROM {table} WHERE user_uid = :u AND deleted_at IS NULL"
for table in sources
)
for row in db.query(f"SELECT MIN(m) AS first FROM ({union})", u=user_uid):
if row["first"]:
return datetime.fromisoformat(row["first"]).date()
return None
HEATMAP_WEEKS = 53
def get_activity_heatmap(user_uid: str) -> list:
calendar = get_activity_calendar(user_uid)
today = datetime.now(timezone.utc).date()
week_start = today - timedelta(days=today.weekday())
start = week_start - timedelta(weeks=HEATMAP_WEEKS - 1)
first = get_first_activity_date(user_uid)
if first:
first_week = first - timedelta(days=first.weekday())
if first_week > start:
start = first_week
weeks = []
for w in range(HEATMAP_WEEKS):
week = []
for d in range(7):
day = start + timedelta(days=w * 7 + d)
iso = day.isoformat()
count = calendar.get(iso, 0)
week.append({"date": iso, "count": count, "level": _activity_level(count)})
weeks.append(week)
return weeks
def get_activity_months(weeks: list) -> list:
if not weeks:
return []
last = len(weeks) - 1
labels = []
for i in range(6):
column = round(i * last / 5)
iso = weeks[column][0]["date"]
labels.append(datetime.fromisoformat(iso).strftime("%b"))
return labels
def get_streaks(user_uid: str) -> dict:
calendar = get_activity_calendar(user_uid)
if not calendar:
return {"current": 0, "longest": 0}
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
date_set = set(dates)
longest = 1
run = 1
for index in range(1, len(dates)):
if (dates[index] - dates[index - 1]).days == 1:
run += 1
else:
run = 1
longest = max(longest, run)
today = datetime.now(timezone.utc).date()
cursor = today
if today not in date_set and (today - timedelta(days=1)) in date_set:
cursor = today - timedelta(days=1)
current = 0
while cursor in date_set:
current += 1
cursor = cursor - timedelta(days=1)
return {"current": current, "longest": longest}
+26
View File
@@ -0,0 +1,26 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import text
from .core import db
def conditional_update_row(
table_name: str, row_uid: str, set_clause: str, where_clause: str, params: dict
) -> int:
sql = (
f"UPDATE {table_name} SET {set_clause}, updated_at = :updated_at "
f"WHERE uid = :row_uid AND ({where_clause})"
)
bind = {
**params,
"updated_at": datetime.now(timezone.utc).isoformat(),
"row_uid": row_uid,
}
with db:
result = db.executable.execute(text(sql), bind)
return result.rowcount
+200
View File
@@ -0,0 +1,200 @@
# retoor <retoor@molodetz.nl>
from .core import db, logger
from .users import get_users_by_uids
from .pagination import build_pagination
from .content import resolve_object_url
def get_attachments(resource_type: str, resource_uid: str) -> list:
if "attachments" not in db.tables:
return []
return list(
db["attachments"].find(
resource_type=resource_type,
resource_uid=resource_uid,
deleted_at=None,
order_by=["created_at"],
)
)
def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
if not resource_uids or "attachments" not in db.tables:
return {}
rows = list(
db["attachments"].find(
db["attachments"].table.columns.resource_uid.in_(resource_uids),
db["attachments"].table.columns.deleted_at.is_(None),
resource_type=resource_type,
)
)
result = {}
for a in rows:
key = a["resource_uid"]
if key not in result:
result[key] = []
result[key].append(a)
return result
def get_news_images_by_uids(news_uids: list) -> dict:
if not news_uids or "news_images" not in db.tables:
return {}
images_table = db["news_images"]
if not images_table.has_column("news_uid"):
return {}
rows = images_table.find(
images_table.table.columns.news_uid.in_(news_uids),
images_table.table.columns.deleted_at.is_(None),
order_by=["uid"],
)
result = {}
for r in rows:
result.setdefault(r["news_uid"], r["url"])
return result
def delete_attachment_record(uid: str) -> None:
if "attachments" not in db.tables:
return
att = db["attachments"].find_one(uid=uid)
if att:
_delete_attachment_file(att)
db["attachments"].delete(id=att["id"])
def delete_attachments(resource_type: str, resource_uid: str) -> None:
if "attachments" not in db.tables:
return
for a in db["attachments"].find(
resource_type=resource_type, resource_uid=resource_uid
):
_delete_attachment_file(a)
db["attachments"].delete(resource_type=resource_type, resource_uid=resource_uid)
def _delete_attachment_file(att: dict) -> None:
from devplacepy.config import ATTACHMENTS_DIR
directory = att.get("directory", "")
stored_name = att.get("stored_name", "")
if not (directory and stored_name):
return
file_path = ATTACHMENTS_DIR / directory / stored_name
try:
file_path.unlink(missing_ok=True)
parent = file_path.parent
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
grandparent = parent.parent
if grandparent.exists() and not any(grandparent.iterdir()):
grandparent.rmdir()
except Exception as e:
logger.warning(f"Failed to delete attachment file {stored_name}: {e}")
def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
from devplacepy.attachments import _row_to_attachment
total = list(
db.query(
"SELECT COUNT(*) AS n FROM attachments "
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL",
u=user_uid,
)
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
"SELECT * FROM attachments "
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL "
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
u=user_uid,
limit=pagination["per_page"],
offset=offset,
)
items = []
for row in rows:
item = _row_to_attachment(row)
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
items.append(item)
return items, pagination
def _decorate_attachment(row: dict) -> dict:
from devplacepy.attachments import _row_to_attachment
item = _row_to_attachment(row)
item["linked"] = bool(item.get("target_type"))
item["target_url"] = (
resolve_object_url(item["target_type"], item["target_uid"])
if item["linked"]
else None
)
return item
def get_user_attachments(
user_uid: str, page: int = 1, per_page: int = 24, linked=None
) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
clause = "user_uid=:u AND deleted_at IS NULL"
if linked is True:
clause += " AND target_type != ''"
elif linked is False:
clause += " AND target_type = ''"
total = list(
db.query(f"SELECT COUNT(*) AS n FROM attachments WHERE {clause}", u=user_uid)
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
f"SELECT * FROM attachments WHERE {clause} "
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
u=user_uid,
limit=pagination["per_page"],
offset=offset,
)
return [_decorate_attachment(row) for row in rows], pagination
def get_user_attachment(uid: str) -> dict | None:
if "attachments" not in db.tables:
return None
row = db["attachments"].find_one(uid=uid, deleted_at=None)
if not row:
return None
return _decorate_attachment(row)
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
if "attachments" not in db.tables:
return [], build_pagination(page, 0, per_page)
from devplacepy.attachments import _row_to_attachment
total = list(
db.query("SELECT COUNT(*) AS n FROM attachments WHERE deleted_at IS NOT NULL")
)[0]["n"]
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = db.query(
"SELECT * FROM attachments WHERE deleted_at IS NOT NULL "
"ORDER BY deleted_at DESC LIMIT :limit OFFSET :offset",
limit=pagination["per_page"],
offset=offset,
)
rows = list(rows)
uploaders = get_users_by_uids([row.get("user_uid") for row in rows])
items = []
for row in rows:
item = _row_to_attachment(row)
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
item["deleted_at"] = row.get("deleted_at", "")
uploader = uploaders.get(row.get("user_uid"))
item["uploader"] = uploader["username"] if uploader else "unknown"
items.append(item)
return items, pagination
+206
View File
@@ -0,0 +1,206 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import (
AWARD_DISPLAY_HOURS_DEFAULT,
AWARD_GIVE_COOLDOWN_HOURS_DEFAULT,
AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT,
)
from .core import db
from .pagination import build_pagination
from .settings import get_int_setting
from .core import get_table, _now_iso
from .users import get_users_by_uids
from .content import resolve_by_slug
from .soft_delete import soft_delete, soft_delete_in
AWARDS_PER_PAGE = 12
def _awards_table():
return get_table("awards")
def award_give_cooldown_hours() -> int:
return max(1, get_int_setting("award_give_cooldown_hours", AWARD_GIVE_COOLDOWN_HOURS_DEFAULT))
def award_receive_cooldown_hours() -> int:
return max(
1, get_int_setting("award_receive_cooldown_hours", AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT)
)
def award_display_hours() -> int:
return max(1, get_int_setting("award_display_hours", AWARD_DISPLAY_HOURS_DEFAULT))
def _cooldown_cutoff(hours: int) -> str:
return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
def has_giver_cooldown(giver_uid: str) -> bool:
if not giver_uid or "awards" not in db.tables:
return False
cutoff = _cooldown_cutoff(award_give_cooldown_hours())
row = _awards_table().find_one(
giver_uid=giver_uid, deleted_at=None, created_at={">=": cutoff}
)
return row is not None
def has_receiver_cooldown(receiver_uid: str) -> bool:
if not receiver_uid or "awards" not in db.tables:
return False
cutoff = _cooldown_cutoff(award_receive_cooldown_hours())
row = _awards_table().find_one(
receiver_uid=receiver_uid, deleted_at=None, created_at={">=": cutoff}
)
return row is not None
def can_receive_award(receiver_uid: str) -> bool:
return not has_receiver_cooldown(receiver_uid)
def can_give_award(giver_uid: str, receiver_uid: str) -> bool:
if not giver_uid or not receiver_uid or giver_uid == receiver_uid:
return False
return not has_giver_cooldown(giver_uid) and not has_receiver_cooldown(receiver_uid)
def _published_filter():
return {"deleted_at": None, "generated_at": {">": ""}}
def count_published_awards(receiver_uid: str) -> int:
if not receiver_uid or "awards" not in db.tables:
return 0
return _awards_table().count(receiver_uid=receiver_uid, **_published_filter())
def _latest_published(receiver_uid: str):
if not receiver_uid or "awards" not in db.tables:
return None
rows = list(
_awards_table().find(
receiver_uid=receiver_uid,
deleted_at=None,
generated_at={">": ""},
order_by=["-generated_at"],
_limit=1,
)
)
return rows[0] if rows else None
def recompute_user_award_stats(receiver_uid: str) -> None:
if not receiver_uid or "users" not in db.tables:
return
count = count_published_awards(receiver_uid)
latest = _latest_published(receiver_uid)
users = get_table("users")
payload = {
"uid": receiver_uid,
"award_count": count,
"last_award_at": latest.get("generated_at") if latest else None,
"last_award_slug": latest.get("slug") if latest else None,
"last_award_uid": latest.get("uid") if latest else None,
}
users.update(payload, ["uid"])
_prominence_cache = TTLCache(ttl=15, max_size=500)
def award_is_prominent(user: dict | None) -> bool:
if not user or not user.get("last_award_at") or not user.get("last_award_uid"):
return False
cached = _prominence_cache.get(user["last_award_uid"])
if cached is not None:
return cached
prominent = _compute_prominence(user["last_award_uid"])
_prominence_cache.set(user["last_award_uid"], prominent)
return prominent
def _compute_prominence(award_uid: str) -> bool:
award = resolve_by_slug(_awards_table(), award_uid)
if not award or not award.get("generated_at"):
return False
try:
published = datetime.fromisoformat(award["generated_at"])
if published.tzinfo is None:
published = published.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
return False
window = timedelta(hours=award_display_hours())
return datetime.now(timezone.utc) - published <= window
def enrich_award(row: dict, givers: dict | None = None) -> dict:
item = dict(row)
giver_uid = row.get("giver_uid", "")
giver = (givers or {}).get(giver_uid) or get_users_by_uids([giver_uid]).get(giver_uid)
item["giver"] = giver
item["image_url"] = f"/awards/{row.get('slug', '')}/256"
item["thumb_url"] = f"/awards/{row.get('slug', '')}/64"
return item
def get_user_awards(receiver_uid: str, page: int = 1, per_page: int = AWARDS_PER_PAGE):
if not receiver_uid or "awards" not in db.tables:
return [], build_pagination(page, 0, per_page)
table = _awards_table()
total = table.count(receiver_uid=receiver_uid, **_published_filter())
offset = max(0, (page - 1) * per_page)
rows = list(
table.find(
receiver_uid=receiver_uid,
deleted_at=None,
generated_at={">": ""},
order_by=["-generated_at"],
_limit=per_page,
_offset=offset,
)
)
giver_uids = [row.get("giver_uid") for row in rows if row.get("giver_uid")]
givers = get_users_by_uids(giver_uids)
items = [enrich_award(row, givers) for row in rows]
return items, build_pagination(page, total, per_page)
def get_prominent_award(profile_user: dict) -> dict | None:
if not award_is_prominent(profile_user):
return None
award = resolve_by_slug(_awards_table(), profile_user.get("last_award_uid", ""))
if not award:
return None
return enrich_award(award)
def revoke_award(award_uid: str, admin_uid: str) -> dict | None:
table = _awards_table()
row = table.find_one(uid=award_uid)
if not row or row.get("deleted_at"):
return None
stamp = _now_iso()
attachment_uids = [
uid
for uid in (
row.get("attachment_uid_512"),
row.get("attachment_uid_256"),
row.get("attachment_uid_64"),
)
if uid
]
soft_delete("awards", admin_uid, stamp=stamp, uid=award_uid)
from devplacepy.attachments import soft_delete_attachments_for
soft_delete_attachments_for("award", [award_uid], admin_uid)
if attachment_uids:
soft_delete_in("attachments", "uid", attachment_uids, admin_uid, stamp=stamp)
recompute_user_award_stats(row.get("receiver_uid", ""))
return row
+155
View File
@@ -0,0 +1,155 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, db, defaultdict
from .users import get_users_by_uids
from .relations import get_blocked_uids
from .engagement import get_reactions_by_targets, get_user_votes, get_vote_counts
def _drop_blocked(raw, user):
if not user:
return raw
blocked = get_blocked_uids(user["uid"])
if not blocked:
return raw
return [c for c in raw if c["user_uid"] not in blocked]
def _build_comment_items(raw, user=None):
uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
user_votes = get_user_votes(user["uid"], cids) if user else {}
reactions = get_reactions_by_targets("comment", cids, user)
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
items = {}
for c in raw:
items[c["uid"]] = {
"comment": c,
"author": users.get(c["user_uid"]),
"time_ago": time_ago(c["created_at"]),
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
"my_vote": user_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
}
return items
def load_comments(target_type, target_uid, user=None):
if "comments" not in db.tables:
return []
comments_table = db["comments"]
raw = list(
comments_table.find(
target_type=target_type,
target_uid=target_uid,
deleted_at=None,
order_by=["created_at"],
)
)
if not raw and target_type == "post":
raw = list(
comments_table.find(
post_uid=target_uid, deleted_at=None, order_by=["created_at"]
)
)
raw = _drop_blocked(raw, user)
if not raw:
return []
cmap = _build_comment_items(raw, user)
top = []
for item in cmap.values():
parent = item["comment"].get("parent_uid")
if parent and parent in cmap:
cmap[parent]["children"].append(item)
else:
top.append(item)
return top
def get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None):
if not target_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
params["lim"] = limit
raw = list(
db.query(
f"SELECT * FROM ("
f" SELECT *, ROW_NUMBER() OVER ("
f" PARTITION BY target_uid ORDER BY created_at DESC, id DESC"
f" ) AS rn FROM comments"
f" WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL"
f") WHERE rn <= :lim ORDER BY target_uid, created_at ASC",
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
items = _build_comment_items(raw, user)
by_target = defaultdict(list)
for c in raw:
by_target[c["target_uid"]].append(c)
result = {}
for target_uid, group in by_target.items():
in_group = {c["uid"] for c in group}
top = []
for c in group:
item = items[c["uid"]]
item["children"] = []
for c in group:
item = items[c["uid"]]
parent = c.get("parent_uid")
if parent and parent in in_group:
items[parent]["children"].append(item)
else:
top.append(item)
result[target_uid] = top
return result
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
return get_recent_comments_by_target_uids("post", post_uids, limit, user)
def load_comments_by_target_uids(target_type, target_uids, user=None):
if not target_uids or "comments" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
raw = list(
db.query(
f"SELECT * FROM comments WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL ORDER BY created_at",
**params,
)
)
raw = _drop_blocked(raw, user)
if not raw:
return {}
from collections import defaultdict
by_uid = defaultdict(list)
for c in raw:
by_uid[c["target_uid"]].append(c)
result = {}
for uid in target_uids:
group = by_uid.get(uid, [])
if not group:
result[uid] = []
continue
cmap = _build_comment_items(group, user)
tree = []
for item in cmap.values():
parent = item["comment"].get("parent_uid")
if parent and parent in cmap:
cmap[parent]["children"].append(item)
else:
tree.append(item)
result[uid] = tree
return result
+208
View File
@@ -0,0 +1,208 @@
# retoor <retoor@molodetz.nl>
from collections import Counter
from devplacepy.cache import TTLCache
from .core import db, get_table, or_
_daily_topic_cache = TTLCache(ttl=60, max_size=1)
_trending_cache = TTLCache(ttl=15, max_size=1)
def resolve_by_slug(table, slug, include_deleted=False):
has_soft_delete = table.has_column("deleted_at")
flt = {} if include_deleted or not has_soft_delete else {"deleted_at": None}
entry = table.find_one(slug=slug, **flt)
if not entry:
entry = table.find_one(uid=slug, **flt)
return entry
def resolve_object_url(target_type: str, target_uid: str) -> str:
if target_type == "post":
post = resolve_by_slug(get_table("posts"), target_uid)
return f"/posts/{post['slug'] or post['uid']}" if post else "/feed"
if target_type == "project":
project = resolve_by_slug(get_table("projects"), target_uid)
return (
f"/projects/{project['slug'] or project['uid']}" if project else "/projects"
)
if target_type == "news":
article = resolve_by_slug(get_table("news"), target_uid)
if article:
return f"/news/{article.get('slug', '') or article['uid']}"
return "/news"
if target_type == "issue":
return f"/issues?highlight={target_uid}"
if target_type == "gist":
gist = resolve_by_slug(get_table("gists"), target_uid)
return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists"
if target_type == "quiz":
quiz = resolve_by_slug(get_table("quizzes"), target_uid)
return f"/quizzes/{quiz['slug'] or quiz['uid']}" if quiz else "/quizzes"
if target_type == "comment":
comment = get_table("comments").find_one(uid=target_uid, deleted_at=None)
if not comment:
return "/feed"
parent_url = resolve_object_url(
comment.get("target_type", "post"),
comment.get("target_uid") or comment.get("post_uid", ""),
)
return f"{parent_url}#comment-{target_uid}"
if target_type == "award":
award = resolve_by_slug(get_table("awards"), target_uid)
if award:
receiver = get_table("users").find_one(uid=award.get("receiver_uid", ""))
if receiver:
return f"/profile/{receiver['username']}?tab=awards#award-{award.get('slug', '')}"
return "/feed"
if target_type == "user":
person = get_table("users").find_one(uid=target_uid)
return f"/profile/{person['username']}" if person else "/feed"
if target_type == "project_file":
node = get_table("project_files").find_one(uid=target_uid)
if not node:
return "/projects"
project = get_table("projects").find_one(uid=node.get("project_uid", ""))
if not project:
return "/projects"
slug = project.get("slug") or project["uid"]
return f"/projects/{slug}/files?path={node.get('path', '')}"
if target_type == "attachment":
attachment = get_table("attachments").find_one(uid=target_uid)
if not attachment:
return "/feed"
parent_type = attachment.get("target_type") or ""
parent_uid = attachment.get("target_uid") or ""
if parent_type and parent_uid:
return resolve_object_url(parent_type, parent_uid)
owner = get_table("users").find_one(uid=attachment.get("user_uid", ""))
return f"/profile/{owner['username']}?tab=media" if owner else "/feed"
if target_type == "message":
message = get_table("messages").find_one(uid=target_uid)
if not message:
return "/messages"
return f"/messages?with_uid={message.get('sender_uid', '')}"
if target_type == "poll":
poll = get_table("polls").find_one(uid=target_uid)
if not poll:
return "/feed"
return resolve_object_url("post", poll.get("post_uid", ""))
if target_type == "workspace":
instance = get_table("instances").find_one(uid=target_uid)
return f"/admin/containers/{instance['uid']}" if instance else "/admin/containers"
if target_type == "devii_output":
return "/devii"
return "/feed"
def get_uids_by_username_match(search, limit=200):
term = (search or "").strip()
if not term or "users" not in db.tables:
return []
rows = db.query(
"SELECT uid FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{term}%",
limit=limit,
)
return [row["uid"] for row in rows]
def text_search_clause(
table, search, fields=("title", "description"), author_field=None
):
if not search or not search.strip() or not table.exists:
return None
columns = table.table.columns
like = f"%{search.strip()}%"
matches = [columns[field].ilike(like) for field in fields if field in columns]
if author_field and author_field in columns:
author_uids = get_uids_by_username_match(search)
if author_uids:
matches.append(columns[author_field].in_(author_uids))
return or_(*matches) if matches else None
def get_daily_topic():
cached = _daily_topic_cache.get("topic")
if cached is not None:
return cached
topic = _load_daily_topic()
_daily_topic_cache.set("topic", topic)
return topic
def _load_daily_topic():
if "news" in db.tables:
article = db["news"].find_one(
status="published", deleted_at=None, order_by=["-synced_at"]
)
if article:
desc = (article.get("description") or "")[:200] or (
article.get("content") or ""
)[:200]
return {
"title": article.get("title", ""),
"summary": desc,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"image_url": article.get("image_url", ""),
}
return {
"title": "Welcome to DevPlace",
"summary": "Stay tuned for the latest dev news.",
}
def get_featured_news(limit=5):
if "news" not in db.tables:
return []
from devplacepy.utils import time_ago
rows = list(
db["news"].find(
show_on_landing=1, deleted_at=None, order_by=["-synced_at"], _limit=limit
)
)
articles = []
for article in rows:
summary = (article.get("description") or "")[:120] or (
article.get("content") or ""
)[:120]
articles.append(
{
"title": article.get("title", ""),
"summary": summary,
"slug": article.get("slug", ""),
"url": article.get("url", ""),
"source_name": article.get("source_name", ""),
"featured": article.get("featured", 0),
"image_url": article.get("image_url", "") or "",
"time_ago": time_ago(article["synced_at"])
if article.get("synced_at")
else "",
}
)
return articles
def get_trending_topics(limit: int = 6) -> list[dict]:
cached = _trending_cache.get("topics")
if cached is not None:
return cached[:limit]
if "posts" not in db.tables or "topic" not in db["posts"].columns:
return []
rows = db.query(
"SELECT topic FROM posts WHERE deleted_at IS NULL "
"AND topic IS NOT NULL AND topic != '' "
"ORDER BY created_at DESC LIMIT 200"
)
counter: Counter[str] = Counter()
for row in rows:
topic = (row["topic"] or "").strip()
if topic:
counter[topic] += 1
topics = [{"topic": t, "count": c} for t, c in counter.most_common(limit)]
_trending_cache.set("topics", topics)
return topics
+165
View File
@@ -0,0 +1,165 @@
# retoor <retoor@molodetz.nl>
import dataset
import logging
from pathlib import Path
from sqlalchemy import or_
from sqlalchemy.pool import NullPool
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import (
DATABASE_URL,
DEFAULT_CORRECTION_PROMPT,
DEFAULT_MODIFIER_PROMPT,
INTERNAL_GATEWAY_URL,
ensure_data_dirs,
)
logger = logging.getLogger(__name__)
ensure_data_dirs()
if DATABASE_URL.startswith("sqlite:///"):
_db_file = DATABASE_URL[len("sqlite:///") :]
if _db_file and _db_file != ":memory:":
Path(_db_file).parent.mkdir(parents=True, exist_ok=True)
db = dataset.connect(
DATABASE_URL,
engine_kwargs={
"connect_args": {
"timeout": 30,
"check_same_thread": False,
},
"poolclass": NullPool,
},
on_connect_statements=[
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=30000",
"PRAGMA cache_size=-8000",
"PRAGMA temp_store=MEMORY",
"PRAGMA mmap_size=268435456",
],
)
def refresh_snapshot() -> None:
connection = db.executable
if connection.in_transaction() and not db.in_transaction:
connection.commit()
_local_cache_versions: dict = {}
_cache_version_cache = TTLCache(ttl=1)
_cache_state_ready = False
def _ensure_cache_state() -> None:
global _cache_state_ready
if _cache_state_ready:
return
with db:
db.query(
"CREATE TABLE IF NOT EXISTS cache_state "
"(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)"
)
_cache_state_ready = True
def get_cache_version(name: str) -> int:
cached = _cache_version_cache.get(name)
if cached is not None:
return cached
try:
_ensure_cache_state()
with db:
rows = list(db.query("SELECT name, version FROM cache_state"))
versions = {row["name"]: int(row["version"]) for row in rows}
except Exception as e:
logger.warning(f"Could not read cache version {name}: {e}")
return 0
for key, version in versions.items():
_cache_version_cache.set(key, version)
version = versions.get(name, 0)
_cache_version_cache.set(name, version)
return version
def bump_cache_version(name: str) -> None:
try:
_ensure_cache_state()
with db:
db.query(
"INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)",
name=name,
)
db.query(
"UPDATE cache_state SET version = version + 1 WHERE name = :name",
name=name,
)
_cache_version_cache.pop(name)
except Exception as e:
logger.warning(f"Could not bump cache version {name}: {e}")
def sync_local_cache(name: str, cache) -> None:
current = get_cache_version(name)
if name not in _local_cache_versions:
_local_cache_versions[name] = current
return
if _local_cache_versions[name] != current:
cache.clear()
_local_cache_versions[name] = current
def _index(db, table, name, columns, *, where=None, unique=False):
try:
if table in db.tables:
cols = ", ".join(columns)
kind = "UNIQUE INDEX" if unique else "INDEX"
clause = f" WHERE {where}" if where else ""
with db:
db.query(
f"CREATE {kind} IF NOT EXISTS {name} ON {table} ({cols}){clause}"
)
except Exception as e:
logger.warning(f"Could not create index {name} on {table}: {e}")
def _drop_index(db, name):
try:
with db:
db.query(f"DROP INDEX IF EXISTS {name}")
except Exception as e:
logger.warning(f"Could not drop index {name}: {e}")
def _uid_index(db, table):
if table not in db.tables or "uid" not in get_table(table).columns:
return
name = f"idx_{table}_uid"
try:
with db:
db.query(f"CREATE UNIQUE INDEX IF NOT EXISTS {name} ON {table} (uid)")
except Exception as e:
logger.warning(f"Unique uid index on {table} failed ({e}); using non-unique")
_index(db, table, name, ["uid"])
def get_table(name):
return db[name]
def _in_clause(uids, prefix="p"):
placeholders = ", ".join(f":{prefix}{i}" for i in range(len(uids)))
params = {f"{prefix}{i}": uid for i, uid in enumerate(uids)}
return placeholders, params
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
+167
View File
@@ -0,0 +1,167 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
from .soft_delete import soft_delete
CUSTOMIZATION_GLOBAL_SCOPE = "global"
CUSTOMIZATION_LANGS = ("css", "js")
_customizations_cache = TTLCache(ttl=300, max_size=100)
def _customization_key(owner_kind: str, owner_id: str, page_type: str) -> str:
return f"{owner_kind}\x1f{owner_id}\x1f{page_type}"
CUSTOMIZATION_PREF_COLUMNS = {
"global": "cust_disable_global",
"pagetype": "cust_disable_pagetype",
}
def get_customization_prefs(owner_kind: str, owner_id: str) -> dict:
if owner_kind != "user" or "users" not in db.tables:
return {"disable_global": False, "disable_pagetype": False}
user = db["users"].find_one(uid=owner_id)
if user is None:
return {"disable_global": False, "disable_pagetype": False}
return {
"disable_global": bool(user.get("cust_disable_global", 0)),
"disable_pagetype": bool(user.get("cust_disable_pagetype", 0)),
}
def set_customization_pref(owner_id: str, category: str, disabled: bool) -> None:
column = CUSTOMIZATION_PREF_COLUMNS.get(category)
if column is None:
raise ValueError(f"Unknown customization category: {category}")
from devplacepy.utils import clear_user_cache
get_table("users").update(
{"uid": owner_id, column: 1 if disabled else 0}, ["uid"]
)
clear_user_cache(owner_id)
bump_cache_version("customizations")
def get_custom_overrides(owner_kind: str, owner_id: str, page_type: str) -> dict:
sync_local_cache("customizations", _customizations_cache)
key = _customization_key(owner_kind, owner_id, page_type)
cached = _customizations_cache.get(key)
if cached is not None:
return cached
result = {"css": "", "js": ""}
if "user_customizations" in db.tables:
prefs = get_customization_prefs(owner_kind, owner_id)
scopes = (CUSTOMIZATION_GLOBAL_SCOPE, page_type)
rows = db["user_customizations"].find(
owner_kind=owner_kind,
owner_id=owner_id,
enabled=1,
deleted_at=None,
)
pieces: dict[str, dict[str, str]] = {lang: {} for lang in CUSTOMIZATION_LANGS}
for row in rows:
lang = row.get("lang")
scope = row.get("scope")
if lang not in pieces or scope not in scopes:
continue
if scope == CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_global"]:
continue
if scope != CUSTOMIZATION_GLOBAL_SCOPE and prefs["disable_pagetype"]:
continue
pieces[lang][scope] = row.get("code") or ""
for lang in CUSTOMIZATION_LANGS:
ordered = [pieces[lang][scope] for scope in scopes if scope in pieces[lang]]
result[lang] = "\n".join(part for part in ordered if part.strip())
_customizations_cache.set(key, result)
return result
def get_custom_override(
owner_kind: str, owner_id: str, scope: str, lang: str
) -> dict | None:
if "user_customizations" not in db.tables:
return None
return db["user_customizations"].find_one(
owner_kind=owner_kind,
owner_id=owner_id,
scope=scope,
lang=lang,
deleted_at=None,
)
def list_custom_overrides(owner_kind: str, owner_id: str) -> list:
if "user_customizations" not in db.tables:
return []
return list(
db["user_customizations"].find(
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
)
)
def set_custom_override(
owner_kind: str, owner_id: str, scope: str, lang: str, code: str
) -> dict:
from devplacepy.utils import generate_uid
table = get_table("user_customizations")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(
owner_kind=owner_kind, owner_id=owner_id, scope=scope, lang=lang
)
if existing:
record = {
"id": existing["id"],
"code": code,
"enabled": 1,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"owner_kind": owner_kind,
"owner_id": owner_id,
"scope": scope,
"lang": lang,
"code": code,
"enabled": 1,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.insert(result)
bump_cache_version("customizations")
return result
def delete_custom_override(
owner_kind: str,
owner_id: str,
scope: str | None = None,
lang: str | None = None,
deleted_by: str | None = None,
) -> int:
if "user_customizations" not in db.tables:
return 0
criteria: dict = {"owner_kind": owner_kind, "owner_id": owner_id}
if scope is not None:
criteria["scope"] = scope
if lang is not None:
criteria["lang"] = lang
count = soft_delete(
"user_customizations", deleted_by or f"{owner_kind}:{owner_id}", **criteria
)
bump_cache_version("customizations")
return int(count)
+119
View File
@@ -0,0 +1,119 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
def _ds_now() -> str:
return datetime.now(timezone.utc).isoformat()
def create_deepsearch_session(
uid: str,
owner_kind: str,
owner_id: str,
query: str,
depth: int,
max_pages: int,
collection: str,
) -> None:
get_table("deepsearch_sessions").insert(
{
"uid": uid,
"owner_kind": owner_kind,
"owner_id": owner_id,
"query": query,
"status": "pending",
"depth": depth,
"max_pages": max_pages,
"score": 0,
"confidence": 0.0,
"source_diversity": 0.0,
"page_count": 0,
"chunk_count": 0,
"collection": collection,
"summary": "",
"created_at": _ds_now(),
"completed_at": "",
"deleted_at": None,
"deleted_by": None,
}
)
def update_deepsearch_session(uid: str, fields: dict) -> None:
if "deepsearch_sessions" not in db.tables:
return
payload = dict(fields)
payload["uid"] = uid
get_table("deepsearch_sessions").update(payload, ["uid"])
def get_deepsearch_session(uid: str) -> dict | None:
if "deepsearch_sessions" not in db.tables:
return None
return get_table("deepsearch_sessions").find_one(uid=uid, deleted_at=None)
def add_deepsearch_message(
uid: str, session_uid: str, role: str, content: str, citations: str = ""
) -> None:
get_table("deepsearch_messages").insert(
{
"uid": uid,
"session_uid": session_uid,
"role": role,
"content": content,
"citations": citations,
"created_at": _ds_now(),
"deleted_at": None,
"deleted_by": None,
}
)
def get_deepsearch_messages(session_uid: str, limit: int = 50) -> list[dict]:
if "deepsearch_messages" not in db.tables:
return []
return list(
get_table("deepsearch_messages").find(
session_uid=session_uid,
deleted_at=None,
order_by=["created_at"],
_limit=limit,
)
)
def get_cached_deepsearch_url(url_hash: str) -> dict | None:
if "deepsearch_url_cache" not in db.tables:
return None
return get_table("deepsearch_url_cache").find_one(url_hash=url_hash)
def upsert_deepsearch_url_cache(
url_hash: str,
url: str,
title: str,
content_hash: str,
status: int,
byte_size: int,
) -> None:
table = get_table("deepsearch_url_cache")
existing = table.find_one(url_hash=url_hash)
row = {
"url_hash": url_hash,
"url": url,
"title": title,
"content_hash": content_hash,
"status": status,
"byte_size": byte_size,
"fetched_at": _ds_now(),
}
if existing:
row["uid"] = existing["uid"]
table.update(row, ["uid"])
else:
from devplacepy.utils import generate_uid
row["uid"] = generate_uid()
table.insert(row)
+95
View File
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
from .soft_delete import soft_delete
EMAIL_ACCOUNT_DEFAULTS: dict[str, object] = {
"imap_host": "",
"imap_port": 993,
"imap_ssl": 1,
"imap_starttls": 0,
"smtp_host": "",
"smtp_port": 587,
"smtp_ssl": 0,
"smtp_starttls": 1,
"username": "",
"password": "",
"from_address": "",
"from_name": "",
}
def list_email_accounts(owner_kind: str, owner_id: str) -> list:
if "email_accounts" not in db.tables:
return []
return list(
db["email_accounts"].find(
owner_kind=owner_kind, owner_id=owner_id, deleted_at=None
)
)
def get_email_account(owner_kind: str, owner_id: str, label: str) -> dict | None:
if "email_accounts" not in db.tables:
return None
return db["email_accounts"].find_one(
owner_kind=owner_kind, owner_id=owner_id, label=label, deleted_at=None
)
def set_email_account(
owner_kind: str, owner_id: str, label: str, fields: dict
) -> dict:
from devplacepy.utils import generate_uid
table = get_table("email_accounts")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id, label=label)
values = {**EMAIL_ACCOUNT_DEFAULTS, **(existing or {}), **fields}
if not values.get("from_address"):
values["from_address"] = values.get("username") or ""
record = {
key: values.get(key, default)
for key, default in EMAIL_ACCOUNT_DEFAULTS.items()
}
if existing:
record.update(
{
"id": existing["id"],
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"owner_kind": owner_kind,
"owner_id": owner_id,
"label": label,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
**record,
}
table.insert(result)
return result
def delete_email_account(
owner_kind: str, owner_id: str, label: str, deleted_by: str | None = None
) -> int:
if "email_accounts" not in db.tables:
return 0
count = soft_delete(
"email_accounts",
deleted_by or f"{owner_kind}:{owner_id}",
owner_kind=owner_kind,
owner_id=owner_id,
label=label,
)
return int(count)
+189
View File
@@ -0,0 +1,189 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, _in_clause, db, defaultdict
_comment_count_cache = TTLCache(ttl=15, max_size=10000)
def get_comment_counts_by_post_uids(post_uids):
if not post_uids or "comments" not in db.tables:
return {}
result = {}
misses = []
for uid in post_uids:
cached = _comment_count_cache.get(uid)
if cached is None:
misses.append(uid)
else:
result[uid] = cached
if misses:
placeholders, params = _in_clause(misses)
rows = db.query(
f"SELECT target_uid, COUNT(*) as c FROM comments WHERE target_type='post' AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid",
**params,
)
fetched = {r["target_uid"]: r["c"] for r in rows}
for uid in misses:
count = fetched.get(uid, 0)
_comment_count_cache.set(uid, count)
result[uid] = count
return result
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders, params = _in_clause(user_uids)
rows = db.query(
f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY user_uid",
**params,
)
return {r["user_uid"]: r["c"] for r in rows}
def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables:
return {}, {}
placeholders, params = _in_clause(target_uids)
rows = db.query(
f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, value",
**params,
)
ups = {}
downs = {}
for r in rows:
if r["value"] == 1:
ups[r["target_uid"]] = r["c"]
else:
downs[r["target_uid"]] = r["c"]
return ups, downs
def get_user_votes(user_uid, target_uids):
if not user_uid or not target_uids or "votes" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["uid"] = user_uid
rows = db.query(
f"SELECT target_uid, value FROM votes WHERE user_uid = :uid AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {r["target_uid"]: r["value"] for r in rows}
def get_reactions_by_targets(target_type, target_uids, user=None):
if not target_uids or "reactions" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL GROUP BY target_uid, emoji",
**params,
)
counts = defaultdict(dict)
for row in rows:
counts[row["target_uid"]][row["emoji"]] = row["c"]
mine = defaultdict(list)
if user:
placeholders, params = _in_clause(target_uids, prefix="m")
params["tt"] = target_type
params["u"] = user["uid"]
for row in db.query(
f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
mine[row["target_uid"]].append(row["emoji"])
result = {}
for uid in target_uids:
result[uid] = {
"counts": dict(counts.get(uid, {})),
"mine": list(mine.get(uid, [])),
}
return result
def get_user_bookmarks(user_uid, target_type, target_uids):
if not user_uid or not target_uids or "bookmarks" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["u"] = user_uid
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["target_uid"] for row in rows}
def get_polls_by_post_uids(post_uids, user=None):
if not post_uids or "polls" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
polls = list(
db.query(
f"SELECT * FROM polls WHERE post_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
)
if not polls:
return {}
poll_uids = [poll["uid"] for poll in polls]
option_placeholders, option_params = _in_clause(poll_uids, prefix="o")
options = list(
db.query(
f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) AND deleted_at IS NULL ORDER BY position",
**option_params,
)
)
counts = defaultdict(dict)
totals = defaultdict(int)
if "poll_votes" in db.tables:
vote_placeholders, vote_params = _in_clause(poll_uids, prefix="v")
for row in db.query(
f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) AND deleted_at IS NULL GROUP BY poll_uid, option_uid",
**vote_params,
):
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
totals[row["poll_uid"]] += row["c"]
user_choice = {}
if user and "poll_votes" in db.tables:
placeholders, params = _in_clause(poll_uids, prefix="m")
params["u"] = user["uid"]
for row in db.query(
f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
):
user_choice[row["poll_uid"]] = row["option_uid"]
options_by_poll = defaultdict(list)
for option in options:
options_by_poll[option["poll_uid"]].append(option)
result = {}
for poll in polls:
poll_uid = poll["uid"]
total = totals.get(poll_uid, 0)
rendered = []
for option in options_by_poll.get(poll_uid, []):
count = counts.get(poll_uid, {}).get(option["uid"], 0)
rendered.append(
{
"uid": option["uid"],
"label": option["label"],
"count": count,
"pct": round(count * 100 / total) if total else 0,
}
)
result[poll["post_uid"]] = {
"uid": poll_uid,
"question": poll["question"],
"options": rendered,
"total": total,
"my_choice": user_choice.get(poll_uid),
}
return result
def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)
+64
View File
@@ -0,0 +1,64 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, db, get_table
from .users import get_users_by_uids
from .pagination import build_pagination
def get_follow_counts(user_uid: str) -> dict:
if "follows" not in db.tables:
return {"followers": 0, "following": 0}
follows = get_table("follows")
return {
"followers": follows.count(following_uid=user_uid, deleted_at=None),
"following": follows.count(follower_uid=user_uid, deleted_at=None),
}
def get_follow_list(
user_uid: str, mode: str, page: int = 1, per_page: int = 25
) -> tuple:
if "follows" not in db.tables:
return [], build_pagination(page, 0, per_page)
follows = get_table("follows")
key = "following_uid" if mode == "followers" else "follower_uid"
other = "follower_uid" if mode == "followers" else "following_uid"
total = follows.count(deleted_at=None, **{key: user_uid})
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
follows.find(
order_by=["-created_at"],
_limit=pagination["per_page"],
_offset=offset,
deleted_at=None,
**{key: user_uid},
)
)
users_map = get_users_by_uids([row[other] for row in rows])
people = []
for row in rows:
person = users_map.get(row[other])
if person:
people.append(
{
"uid": person["uid"],
"username": person["username"],
"bio": (person.get("bio") or "")[:140],
"last_seen": person.get("last_seen"),
"followed_at": row.get("created_at"),
}
)
return people, pagination
def get_following_among(follower_uid: str, target_uids: list) -> set:
if not follower_uid or not target_uids or "follows" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["f"] = follower_uid
rows = db.query(
f"SELECT following_uid FROM follows WHERE follower_uid = :f AND following_uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
return {row["following_uid"] for row in rows}
+59
View File
@@ -0,0 +1,59 @@
# retoor <retoor@molodetz.nl>
from .core import _now_iso, datetime, db, get_table, timezone
from .soft_delete import soft_delete
def record_fork(
source_project_uid: str, forked_project_uid: str, forked_by_uid: str
) -> None:
from devplacepy.utils import generate_uid
get_table("project_forks").insert(
{
"uid": generate_uid(),
"source_project_uid": source_project_uid,
"forked_project_uid": forked_project_uid,
"forked_by_uid": forked_by_uid,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
def get_fork_parent(forked_project_uid: str) -> dict | None:
if "project_forks" not in db.tables:
return None
relation = get_table("project_forks").find_one(
forked_project_uid=forked_project_uid, deleted_at=None
)
if not relation:
return None
return get_table("projects").find_one(
uid=relation["source_project_uid"], deleted_at=None
)
def count_forks(source_project_uid: str) -> int:
if "project_forks" not in db.tables:
return 0
return get_table("project_forks").count(
source_project_uid=source_project_uid, deleted_at=None
)
def soft_delete_fork_relations(project_uid: str, deleted_by: str) -> None:
if "project_forks" not in db.tables:
return
stamp = _now_iso()
soft_delete("project_forks", deleted_by, stamp=stamp, forked_project_uid=project_uid)
soft_delete("project_forks", deleted_by, stamp=stamp, source_project_uid=project_uid)
def delete_fork_relations(project_uid: str) -> None:
if "project_forks" not in db.tables:
return
forks = get_table("project_forks")
forks.delete(forked_project_uid=project_uid)
forks.delete(source_project_uid=project_uid)
+332
View File
@@ -0,0 +1,332 @@
# retoor <retoor@molodetz.nl>
from datetime import date, datetime, timezone
from .core import _in_clause, _now_iso, db, get_table
from .settings import get_int_setting
REPORTABLE_TARGETS: dict[str, str] = {
"post": "posts",
"comment": "comments",
"gist": "gists",
"project": "projects",
"project_file": "project_files",
"news": "news",
"attachment": "attachments",
"message": "messages",
"quiz": "quizzes",
"poll": "polls",
"award": "awards",
"user": "users",
"issue": "issue_tickets",
"workspace": "instances",
"devii_output": "devii_conversations",
}
MATURITY_TARGETS: set[str] = {
"post",
"comment",
"gist",
"project",
"news",
"attachment",
"quiz",
}
UNREPORTABLE_TABLES: dict[str, str] = {
"news_images": "child rows of a reportable news article",
"poll_options": "child rows of a reportable poll",
"quiz_questions": "child rows of a reportable quiz",
"quiz_options": "child rows of a reportable quiz",
"tunnels": "child rows of a reportable workspace instance",
"issue_comment_authors": "authorship index for reportable issue comments",
"votes": "engagement counters, carry no authored content",
"reactions": "engagement counters, carry no authored content",
"bookmarks": "private to the owner",
"follows": "relationship rows, carry no authored content",
"poll_votes": "private ballots",
"quiz_attempts": "private to the participant",
"quiz_answers": "private to the participant",
"sessions": "authentication state",
"access_tokens": "authentication state",
"devrant_tokens": "authentication state",
"user_relations": "private block and mute lists",
"notification_preferences": "private to the owner",
"user_customizations": "runs only in the owner's own browser",
"devii_tasks": "private to the owner",
"devii_lessons": "private to the owner",
"devii_virtual_tools": "private to the owner",
"deepsearch_sessions": "private to the owner",
"deepsearch_messages": "private to the owner",
"isslop_analyses": "generated from a public URL, not authored content",
"email_accounts": "private mailbox credentials",
"instance_schedules": "child rows of a reportable workspace instance",
"workspace_flags": "moderation records, not authored content",
"workspace_quota_rules": "administrator-set limits, not authored content",
"workspace_editor_prefs": (
"private per-user editor configuration, never shown to another member"
),
"content_reports": "moderation records, readable only by the reporter and moderators",
"moderation_actions": "moderation records, not authored content",
"content_maturity": "moderation labels, not authored content",
"user_consents": "private consent history of the account holder",
"backup_schedules": "operator configuration",
"project_forks": "lineage index for reportable projects",
"seo_metadata": "generated metadata for reportable content",
}
REPORT_REASONS: dict[str, str] = {
"hate": "Hate speech or discriminatory content",
"violence": "Realistic violence or threats",
"weapons": "Weapons or dangerous instructions",
"sexual": "Sexual or pornographic content",
"religious": "Content targeting religion or belief",
"misinformation": "False or misleading information",
"exploitative": "Content exploiting a person",
"harassment": "Harassment or bullying",
"spam": "Spam or unwanted promotion",
"intellectual_property": "Copyright or trademark infringement",
"self_harm": "Self-harm or suicide",
"illegal": "Illegal activity",
"other": "Something else",
}
def report_reason_options() -> list[dict[str, str]]:
return [{"key": key, "label": label} for key, label in REPORT_REASONS.items()]
REPORT_STATUSES: tuple[str, ...] = ("open", "acknowledged", "actioned", "dismissed")
REPORT_OPEN_STATUSES: tuple[str, ...] = ("open", "acknowledged")
REPORT_SEVERITIES: tuple[str, ...] = ("info", "warn", "critical")
REPORT_ORIGINS: tuple[str, ...] = ("member", "filter")
MODERATION_ACTIONS: tuple[str, ...] = (
"remove_content",
"restore_content",
"warn",
"suspend",
"ban",
"lift",
"dismiss",
"escalate",
)
MATURITY_LEVELS: tuple[str, ...] = ("general", "mature", "restricted")
MATURITY_SOURCES: tuple[str, ...] = ("author", "filter", "moderator")
CONSENT_KINDS: dict[str, str] = {
"terms": "Terms of Service and Community Guidelines",
"privacy": "Privacy Policy",
"ai_third_party": "Processing of your content by a third-party AI provider",
"activity_recording": "Recording of your presence and session activity",
"container_credentials": (
"Sharing your DevPlace credentials with software another member runs "
"in a container"
),
}
CONSENT_STATES: tuple[str, ...] = ("granted", "withdrawn")
AGE_BANDS: tuple[str, ...] = ("under_min", "13_15", "16_17", "adult")
ADULT_AGE = 18
TEEN_AGE = 16
YOUNG_TEEN_AGE = 13
MINIMUM_AGE_FLOOR = YOUNG_TEEN_AGE
DEFAULT_MINIMUM_AGE = TEEN_AGE
SYSTEM_ACTOR = "system"
REPORTS_TABLE = "content_reports"
ACTIONS_TABLE = "moderation_actions"
MATURITY_TABLE = "content_maturity"
CONSENTS_TABLE = "user_consents"
MODERATION_TABLES: tuple[str, ...] = (
REPORTS_TABLE,
ACTIONS_TABLE,
MATURITY_TABLE,
CONSENTS_TABLE,
)
def years_between(born: date, today: date) -> int:
years = today.year - born.year
if (today.month, today.day) < (born.month, born.day):
years -= 1
return years
def minimum_age() -> int:
return max(
MINIMUM_AGE_FLOOR,
get_int_setting("moderation_minimum_age", DEFAULT_MINIMUM_AGE),
)
def age_band_for(age: int) -> str:
if age >= ADULT_AGE:
return "adult"
if age >= TEEN_AGE:
return "16_17"
if age >= YOUNG_TEEN_AGE:
return "13_15"
return "under_min"
def band_allows_mature(band: str) -> bool:
return band == "adult"
def band_allows_restricted(band: str) -> bool:
return band == "adult"
def get_maturity_by_targets(target_type: str, uids: list[str]) -> dict[str, dict]:
uids = [uid for uid in (uids or []) if uid]
if not uids or MATURITY_TABLE not in db.tables:
return {}
placeholders, params = _in_clause(uids)
params["tt"] = target_type
rows = db.query(
f"SELECT target_uid, level, source FROM {MATURITY_TABLE} "
f"WHERE target_type = :tt AND target_uid IN ({placeholders}) "
f"AND deleted_at IS NULL",
**params,
)
return {
row["target_uid"]: {"level": row["level"], "source": row["source"]}
for row in rows
}
def get_maturity(target_type: str, target_uid: str) -> dict:
found = get_maturity_by_targets(target_type, [target_uid])
return found.get(target_uid, {"level": "general", "source": ""})
def set_maturity(
target_type: str, target_uid: str, level: str, source: str, set_by: str
) -> dict | None:
if target_type not in MATURITY_TARGETS or level not in MATURITY_LEVELS:
return None
from devplacepy.utils import generate_uid
table = get_table(MATURITY_TABLE)
existing = table.find_one(target_type=target_type, target_uid=target_uid)
now = _now_iso()
if existing:
table.update(
{
"id": existing["id"],
"level": level,
"source": source,
"set_by": set_by,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
},
["id"],
)
return table.find_one(id=existing["id"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"target_type": target_type,
"target_uid": target_uid,
"level": level,
"source": source,
"set_by": set_by,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def list_consents(owner_kind: str, owner_id: str) -> list[dict]:
if not owner_id or CONSENTS_TABLE not in db.tables:
return []
return list(
get_table(CONSENTS_TABLE).find(
owner_kind=owner_kind,
owner_id=owner_id,
deleted_at=None,
order_by=["-created_at"],
)
)
def consent_state(owner_kind: str, owner_id: str, kind: str) -> dict | None:
if not owner_id or CONSENTS_TABLE not in db.tables:
return None
rows = list(
get_table(CONSENTS_TABLE).find(
owner_kind=owner_kind,
owner_id=owner_id,
kind=kind,
deleted_at=None,
order_by=["-created_at", "-id"],
_limit=1,
)
)
return rows[0] if rows else None
def consent_granted(owner_kind: str, owner_id: str, kind: str) -> bool:
row = consent_state(owner_kind, owner_id, kind)
return bool(row and row.get("state") == "granted")
def set_consent(
owner_kind: str, owner_id: str, kind: str, granted: bool, version: str = "1"
) -> dict | None:
if kind not in CONSENT_KINDS or not owner_id:
return None
from devplacepy.utils import generate_uid
table = get_table(CONSENTS_TABLE)
now = _now_iso()
current = consent_state(owner_kind, owner_id, kind)
if current and not granted and current.get("state") == "granted":
table.update({"id": current["id"], "withdrawn_at": now}, ["id"])
uid = generate_uid()
table.insert(
{
"uid": uid,
"owner_kind": owner_kind,
"owner_id": owner_id,
"kind": kind,
"version": version,
"state": "granted" if granted else "withdrawn",
"granted_at": now if granted else "",
"withdrawn_at": "" if granted else now,
"created_at": now,
"deleted_at": None,
"deleted_by": None,
}
)
return table.find_one(uid=uid)
def suspension_active(user: dict | None) -> bool:
if not user:
return False
until = (user.get("suspended_until") or "").strip()
if not until:
return False
try:
expiry = datetime.fromisoformat(until)
except ValueError:
return False
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
return expiry > datetime.now(timezone.utc)
+203
View File
@@ -0,0 +1,203 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, datetime, db, get_table, sync_local_cache, timezone
from .settings import get_int_setting, set_setting
from .soft_delete import soft_delete
NOTIFICATION_TYPES = [
{"key": "comment", "label": "Comments", "description": "Someone comments on your post"},
{"key": "reply", "label": "Replies", "description": "Someone replies to your comment"},
{"key": "mention", "label": "Mentions", "description": "Someone mentions you with @username"},
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
{"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"},
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
{"key": "workspace", "label": "Dev workspaces", "description": "Idle, quota, retention and moderation notices for your dev workspaces"},
{"key": "moderation", "label": "Moderation", "description": "Acknowledgements for reports you file and decisions taken on your content"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
]
NOTIFICATION_CHANNELS = ("in_app", "push", "telegram")
_NOTIFICATION_CHANNEL_COLUMNS = {
"in_app": "in_app_enabled",
"push": "push_enabled",
"telegram": "telegram_enabled",
}
_NOTIFICATION_CHANNEL_DEFAULTS = {"in_app": 1, "push": 1, "telegram": 0}
_NOTIFICATION_TYPE_KEYS = {entry["key"] for entry in NOTIFICATION_TYPES}
_notification_prefs_cache = TTLCache(ttl=300, max_size=500)
def _notification_default(notification_type: str, channel: str) -> bool:
fallback = _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1)
return get_int_setting(f"notif_default_{notification_type}_{channel}", fallback) != 0
def get_notification_default(notification_type: str, channel: str) -> bool:
return _notification_default(notification_type, channel)
def set_notification_default(
notification_type: str, channel: str, enabled: bool
) -> None:
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
raise ValueError(f"Unknown notification channel: {channel}")
set_setting(f"notif_default_{notification_type}_{channel}", "1" if enabled else "0")
def _notification_overrides(user_uid: str) -> dict:
sync_local_cache("notif_prefs", _notification_prefs_cache)
cached = _notification_prefs_cache.get(user_uid)
if cached is not None:
return cached
overrides: dict = {}
if "notification_preferences" in db.tables:
for row in db["notification_preferences"].find(
user_uid=user_uid, deleted_at=None
):
overrides[row["notification_type"]] = {
channel: bool(
row.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1))
)
for channel, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
_notification_prefs_cache.set(user_uid, overrides)
return overrides
def notification_enabled(user_uid: str, notification_type: str, channel: str) -> bool:
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
return True
override = _notification_overrides(user_uid).get(notification_type)
if override is not None:
return bool(override[channel])
return _notification_default(notification_type, channel)
def get_notification_prefs(user_uid: str) -> list:
overrides = _notification_overrides(user_uid)
result = []
for entry in NOTIFICATION_TYPES:
key = entry["key"]
override = overrides.get(key)
channels = {
channel: bool(override[channel])
if override
else _notification_default(key, channel)
for channel in _NOTIFICATION_CHANNEL_COLUMNS
}
result.append(
{
"key": key,
"label": entry["label"],
"description": entry["description"],
**channels,
"customized": override is not None,
}
)
return result
def set_notification_pref(
user_uid: str, notification_type: str, channel: str, enabled: bool
) -> dict:
if notification_type not in _NOTIFICATION_TYPE_KEYS:
raise ValueError(f"Unknown notification type: {notification_type}")
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
raise ValueError(f"Unknown notification channel: {channel}")
from devplacepy.utils import generate_uid
table = get_table("notification_preferences")
now = datetime.now(timezone.utc).isoformat()
existing = table.find_one(user_uid=user_uid, notification_type=notification_type)
if existing:
values = {
name: bool(
existing.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(name, 1))
)
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
else:
values = {
name: _notification_default(notification_type, name)
for name in _NOTIFICATION_CHANNEL_COLUMNS
}
values[channel] = enabled
columns = {
column: 1 if values[name] else 0
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
if existing:
record = {
"id": existing["id"],
**columns,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.update(record, ["id"])
result = {**existing, **record}
else:
result = {
"uid": generate_uid(),
"user_uid": user_uid,
"notification_type": notification_type,
**columns,
"created_at": now,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
table.insert(result)
bump_cache_version("notif_prefs")
return result
def reset_notification_prefs(user_uid: str, deleted_by: str | None = None) -> int:
if "notification_preferences" not in db.tables:
return 0
count = soft_delete(
"notification_preferences", deleted_by or f"user:{user_uid}", user_uid=user_uid
)
bump_cache_version("notif_prefs")
return int(count)
def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
if not user_uid or not target_url or "notifications" not in db.tables:
return 0
notifications_table = get_table("notifications")
ids = [
n["id"]
for n in notifications_table.find(user_uid=user_uid, read=False)
if n.get("target_url")
and (
n["target_url"] == target_url
or n["target_url"].startswith(f"{target_url}#")
)
]
if not ids:
return 0
with db:
for notification_id in ids:
notifications_table.update({"id": notification_id, "read": True}, ["id"])
from devplacepy.templating import clear_unread_cache
clear_unread_cache(user_uid)
return len(ids)
+108
View File
@@ -0,0 +1,108 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cache import TTLCache
from .core import db, get_table
from .relations import get_blocked_uids
PAGE_SIZE = 25
_user_post_count_cache = TTLCache(ttl=15, max_size=2000)
def paginate(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
viewer_uid=None,
**filters,
):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if table.has_column("deleted_at") and "deleted_at" not in filters:
clauses.append(table.table.columns.deleted_at.is_(None))
if viewer_uid and table.has_column("user_uid"):
blocked = get_blocked_uids(viewer_uid)
if blocked:
clauses.append(table.table.columns.user_uid.notin_(blocked))
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
has_more = len(rows) > PAGE_SIZE
rows = rows[:PAGE_SIZE]
next_cursor = rows[-1][cursor_field] if has_more and rows else None
return rows, next_cursor
def interleave_by_author(rows, uid_key="user_uid"):
remaining = list(rows)
spread = []
last_owner = object()
while remaining:
pick = next(
(
index
for index, row in enumerate(remaining)
if row.get(uid_key) != last_owner
),
0,
)
row = remaining.pop(pick)
spread.append(row)
last_owner = row.get(uid_key)
return spread
def paginate_diverse(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
uid_key="user_uid",
viewer_uid=None,
**filters,
):
rows, next_cursor = paginate(
table,
*clauses,
before=before,
order=order,
cursor_field=cursor_field,
viewer_uid=viewer_uid,
**filters,
)
return interleave_by_author(rows, uid_key=uid_key), next_cursor
def clear_user_post_count(user_uid: str) -> None:
_user_post_count_cache.pop(user_uid)
def get_user_post_count(user_uid: str) -> int:
cached = _user_post_count_cache.get(user_uid)
if cached is not None:
return cached
if "posts" not in db.tables:
return 0
count = get_table("posts").count(user_uid=user_uid, deleted_at=None)
_user_post_count_cache.set(user_uid, count)
return count
def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))
return {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
"prev_page": page - 1,
"next_page": page + 1,
}
+191
View File
@@ -0,0 +1,191 @@
# retoor <retoor@molodetz.nl>
import os
from .core import TTLCache, _in_clause, _now_iso, db, get_table
from .users import get_users_by_uids
from .soft_delete import soft_delete, soft_delete_in
VOTABLE_TARGETS: dict[str, str] = {
"post": "posts",
"project": "projects",
"gist": "gists",
"comment": "comments",
"quiz": "quizzes",
}
STAR_TARGETS: set[str] = {"post", "project", "gist", "quiz"}
RANKING_TTL = int(os.environ.get("DEVPLACE_RANKING_TTL", "60"))
_authors_cache = TTLCache(ttl=RANKING_TTL, max_size=200)
_stars_cache = TTLCache(ttl=15, max_size=2000)
def _ranked_authors() -> list:
cached = _authors_cache.get("ranked")
if cached is not None:
return cached
tables = db.tables
sources = [
(target_type, table_name)
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in tables
]
if "votes" not in tables or not sources:
_authors_cache.set("ranked", [])
return []
target_union = " UNION ALL ".join(
f"SELECT uid, user_uid, '{target_type}' AS target_type FROM {table_name} WHERE deleted_at IS NULL"
for target_type, table_name in sources
)
rows = db.query(
f"SELECT t.user_uid, SUM(v.value) AS total "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL "
f"GROUP BY t.user_uid HAVING SUM(v.value) > 0 ORDER BY total DESC"
)
ranked = [(row["user_uid"], row["total"]) for row in rows]
users_map = get_users_by_uids([uid for uid, _ in ranked])
authors = []
for uid, total in ranked:
user = users_map.get(uid)
if user:
author = dict(user)
author["stars"] = total
authors.append(author)
_authors_cache.set("ranked", authors)
_authors_cache.set(
"rank_map",
{author["uid"]: position for position, author in enumerate(authors, start=1)},
)
return authors
def _rank_map() -> dict:
cached = _authors_cache.get("rank_map")
if cached is not None:
return cached
_ranked_authors()
return _authors_cache.get("rank_map") or {}
def get_top_authors(limit: int = 5) -> list:
return _ranked_authors()[:limit]
def get_leaderboard(limit: int = 50, offset: int = 0) -> list:
sliced = _ranked_authors()[offset : offset + limit]
leaderboard = []
for position, author in enumerate(sliced, start=offset + 1):
entry = dict(author)
entry["rank"] = position
leaderboard.append(entry)
return leaderboard
def get_user_rank(user_uid: str):
return _rank_map().get(user_uid)
def clear_user_stars(user_uid: str) -> None:
_stars_cache.pop(user_uid)
def get_user_stars(user_uid: str) -> int:
cached = _stars_cache.get(user_uid)
if cached is not None:
return cached
tables = db.tables
if "votes" not in tables:
return 0
target_union = " UNION ALL ".join(
f"SELECT uid, '{target_type}' AS target_type FROM {table_name} WHERE user_uid = :u AND deleted_at IS NULL"
for target_type, table_name in VOTABLE_TARGETS.items()
if table_name in tables
)
if not target_union:
return 0
total = 0
for row in db.query(
f"SELECT COALESCE(SUM(v.value), 0) AS s "
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
f"WHERE v.deleted_at IS NULL",
u=user_uid,
):
total = row["s"] or 0
break
_stars_cache.set(user_uid, total)
return total
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return
if target_type in STAR_TARGETS:
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
stamp = _now_iso()
soft_delete_in(
"reactions", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
soft_delete_in(
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid, deleted_at=None):
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
tables = db.tables
if "reactions" in tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
db.query(
f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if "bookmarks" in tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
with db:
db.query(
f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})",
**params,
)
if target_type == "post" and "polls" in tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid):
if "poll_votes" in tables:
db["poll_votes"].delete(poll_uid=poll["uid"])
if "poll_options" in tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
table_name = VOTABLE_TARGETS.get(target_type)
if not table_name:
return None
row = get_table(table_name).find_one(uid=target_uid, deleted_at=None)
return row["user_uid"] if row else None
+45
View File
@@ -0,0 +1,45 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, sync_local_cache
_relations_cache = TTLCache(ttl=300, max_size=2000)
def get_user_relations(viewer_uid: str | None) -> dict:
if not viewer_uid:
return {"block": frozenset(), "mute": frozenset()}
sync_local_cache("relations", _relations_cache)
cached = _relations_cache.get(viewer_uid)
if cached is not None:
return cached
block: set = set()
mute: set = set()
if "user_relations" in db.tables:
for row in db["user_relations"].find(user_uid=viewer_uid, deleted_at=None):
target = row["target_uid"]
if row["kind"] == "block":
block.add(target)
elif row["kind"] == "mute":
mute.add(target)
result = {"block": frozenset(block), "mute": frozenset(mute)}
_relations_cache.set(viewer_uid, result)
return result
def get_blocked_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["block"]
def get_muted_uids(viewer_uid: str | None) -> frozenset:
return get_user_relations(viewer_uid)["mute"]
def get_silenced_uids(viewer_uid: str | None) -> frozenset:
relations = get_user_relations(viewer_uid)
return relations["block"] | relations["mute"]
def invalidate_user_relations(viewer_uid: str) -> None:
_relations_cache.pop(viewer_uid)
bump_cache_version("relations")
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
from .core import _in_clause, _now_iso, db, get_table
SEO_META_TYPES = ("post", "project", "gist", "news", "issue", "quiz")
def get_seo_metadata(target_type: str, target_uid: str) -> dict | None:
if not target_type or not target_uid or "seo_metadata" not in db.tables:
return None
row = get_table("seo_metadata").find_one(
target_type=target_type,
target_uid=str(target_uid),
status="ready",
deleted_at=None,
)
return dict(row) if row else None
def get_seo_metadata_batch(target_type: str, uids: list) -> dict:
uids = [str(uid) for uid in (uids or []) if uid]
if not uids or "seo_metadata" not in db.tables:
return {}
placeholders, params = _in_clause(uids)
params["tt"] = target_type
rows = db.query(
"SELECT * FROM seo_metadata WHERE target_type = :tt AND status = 'ready' "
f"AND deleted_at IS NULL AND target_uid IN ({placeholders})",
**params,
)
return {row["target_uid"]: dict(row) for row in rows}
def has_fresh_seo_metadata(target_type: str, target_uid: str) -> bool:
return get_seo_metadata(target_type, target_uid) is not None
def upsert_seo_metadata(
target_type: str,
target_uid: str,
seo_title: str,
seo_description: str,
seo_keywords: str,
status: str,
source: str,
) -> None:
if not target_type or not target_uid:
return
from devplacepy.utils import generate_uid
table = get_table("seo_metadata")
now = _now_iso()
generated_at = now if status == "ready" else ""
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
payload = {
"target_type": target_type,
"target_uid": str(target_uid),
"seo_title": seo_title,
"seo_description": seo_description,
"seo_keywords": seo_keywords,
"status": status,
"source": source,
"generated_at": generated_at,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
}
if existing:
payload["id"] = existing["id"]
table.update(payload, ["id"])
else:
payload["uid"] = generate_uid()
payload["created_at"] = now
table.insert(payload)
def mark_seo_metadata_stale(target_type: str, target_uid: str) -> None:
if not target_type or not target_uid or "seo_metadata" not in db.tables:
return
table = get_table("seo_metadata")
existing = table.find_one(target_type=target_type, target_uid=str(target_uid))
if existing:
table.update(
{"id": existing["id"], "status": "pending", "updated_at": _now_iso()},
["id"],
)
+48
View File
@@ -0,0 +1,48 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, get_table, sync_local_cache
def internal_gateway_key() -> str:
return get_setting("gateway_internal_key", "")
_settings_cache = TTLCache(ttl=60, max_size=100)
def get_setting(key: str, default: str = "") -> str:
sync_local_cache("settings", _settings_cache)
cached = _settings_cache.get(key)
if cached is not None:
return cached
if "site_settings" not in db.tables:
return default
entry = db["site_settings"].find_one(key=key)
if entry is None:
return default
_settings_cache.set(key, entry["value"])
return entry["value"]
def get_int_setting(key: str, default: int) -> int:
raw = get_setting(key, str(default))
try:
return int(raw)
except (TypeError, ValueError):
return default
def set_setting(key: str, value: str) -> None:
settings = get_table("site_settings")
existing = settings.find_one(key=key)
if existing:
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
else:
settings.insert({"uid": f"setting_{key}", "key": key, "value": value})
_settings_cache.set(key, value)
bump_cache_version("settings")
def clear_settings_cache() -> None:
_settings_cache.clear()
bump_cache_version("settings")
+204
View File
@@ -0,0 +1,204 @@
# retoor <retoor@molodetz.nl>
from .core import _drop_index, _in_clause, _index, _now_iso, db
from .pagination import build_pagination
SOFT_DELETE_TABLES = [
"posts",
"comments",
"gists",
"projects",
"news",
"news_images",
"project_files",
"attachments",
"votes",
"reactions",
"bookmarks",
"follows",
"poll_votes",
"polls",
"poll_options",
"sessions",
"instances",
"instance_schedules",
"tunnels",
"workspace_flags",
"workspace_quota_rules",
"workspace_editor_prefs",
"backup_schedules",
"devii_conversations",
"devii_tasks",
"devii_lessons",
"devii_virtual_tools",
"user_customizations",
"project_forks",
"issue_tickets",
"issue_comment_authors",
"notification_preferences",
"deepsearch_sessions",
"deepsearch_messages",
"isslop_analyses",
"devrant_tokens",
"access_tokens",
"email_accounts",
"user_relations",
"seo_metadata",
"awards",
"quizzes",
"quiz_questions",
"quiz_options",
"quiz_attempts",
"quiz_answers",
"content_reports",
"moderation_actions",
"content_maturity",
"user_consents",
]
def ensure_soft_delete_columns(table, *, db_handle=None):
handle = db_handle or db
if table not in handle.tables:
return
target = handle[table]
if not target.has_column("deleted_at"):
target.create_column_by_example("deleted_at", "")
if not target.has_column("deleted_by"):
target.create_column_by_example("deleted_by", "")
_drop_index(handle, f"idx_{table}_deleted")
_index(
handle,
table,
f"idx_{table}_trash",
["deleted_at"],
where="deleted_at IS NOT NULL",
)
def soft_delete(table_name, deleted_by, *, stamp=None, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = list(table.find(deleted_at=None, **criteria))
if not rows:
return 0
stamp = stamp or _now_iso()
for row in rows:
table.update(
{"id": row["id"], "deleted_at": stamp, "deleted_by": deleted_by}, ["id"]
)
return len(rows)
def soft_delete_in(table_name, column, uids, deleted_by, *, stamp=None, **extra):
uids = [uid for uid in (uids or []) if uid]
if not uids or table_name not in db.tables:
return 0
if not db[table_name].has_column("deleted_at"):
return 0
placeholders, params = _in_clause(uids)
params["dat"] = stamp or _now_iso()
params["dby"] = deleted_by
extra_sql = ""
for index, (key, value) in enumerate(extra.items()):
params[f"x{index}"] = value
extra_sql += f" AND {key} = :x{index}"
with db:
db.query(
f"UPDATE {table_name} SET deleted_at = :dat, deleted_by = :dby "
f"WHERE {column} IN ({placeholders}) AND deleted_at IS NULL{extra_sql}",
**params,
)
return len(uids)
def restore(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
if not table.has_column("deleted_at"):
return 0
rows = [row for row in table.find(**criteria) if row.get("deleted_at")]
for row in rows:
table.update({"id": row["id"], "deleted_at": None, "deleted_by": None}, ["id"])
return len(rows)
def purge(table_name, **criteria):
if table_name not in db.tables:
return 0
table = db[table_name]
count = table.count(**criteria)
table.delete(**criteria)
return int(count)
def list_deleted(table_name, page=1, per_page=25):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return [], build_pagination(page, 0, per_page)
table = db[table_name]
column = table.table.columns.deleted_at
total = table.count(column.isnot(None))
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
table.find(
column.isnot(None),
order_by=["-deleted_at"],
_limit=pagination["per_page"],
_offset=offset,
)
)
return rows, pagination
def count_deleted(table_name):
if table_name not in db.tables or not db[table_name].has_column("deleted_at"):
return 0
table = db[table_name]
return int(table.count(table.table.columns.deleted_at.isnot(None)))
def restore_event(stamp):
if not stamp:
return 0
restored = 0
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
restored += int(
db.query(
f"SELECT COUNT(*) AS n FROM {table_name} WHERE deleted_at = :s",
s=stamp,
).__next__()["n"]
)
with db:
db.query(
f"UPDATE {table_name} SET deleted_at = NULL, deleted_by = NULL "
f"WHERE deleted_at = :s",
s=stamp,
)
return restored
def purge_event(stamp):
if not stamp:
return []
purged = []
for table_name in SOFT_DELETE_TABLES:
if table_name in db.tables and db[table_name].has_column("deleted_at"):
rows = list(
db.query(
f"SELECT * FROM {table_name} WHERE deleted_at = :s", s=stamp
)
)
if rows:
purged.append((table_name, rows))
with db:
db.query(
f"DELETE FROM {table_name} WHERE deleted_at = :s", s=stamp
)
return purged
+151
View File
@@ -0,0 +1,151 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, datetime, db, timedelta, timezone
from .ranking import get_top_authors
_stats_cache = TTLCache(ttl=30, max_size=50)
def get_site_stats() -> dict:
cached = _stats_cache.get("site")
if cached is not None:
return cached
today_start = (
datetime.now(timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
.isoformat()
)
stats = {
"total_members": db["users"].count() if "users" in db.tables else 0,
"posts_today": db["posts"].count(
created_at={">=": today_start}, deleted_at=None
)
if "posts" in db.tables
else 0,
"total_projects": db["projects"].count(deleted_at=None)
if "projects" in db.tables
else 0,
"total_gists": db["gists"].count(deleted_at=None)
if "gists" in db.tables
else 0,
}
_stats_cache.set("site", stats)
return stats
_analytics_cache = TTLCache(ttl=300, max_size=50)
def get_platform_analytics(top_n: int = 10) -> dict:
top_n = max(1, min(int(top_n or 10), 50))
cache_key = f"analytics:{top_n}"
cached = _analytics_cache.get(cache_key)
if cached is not None:
return cached
now = datetime.now(timezone.utc)
d1 = (now - timedelta(days=1)).isoformat()
d7 = (now - timedelta(days=7)).isoformat()
d30 = (now - timedelta(days=30)).isoformat()
active_24h = active_7d = active_30d = 0
sources = [
table
for table in ("posts", "comments", "gists", "projects")
if table in db.tables
]
if sources:
union = " UNION ALL ".join(
f"SELECT user_uid, created_at FROM {table} WHERE deleted_at IS NULL"
for table in sources
)
rows = db.query(
"SELECT "
"COUNT(DISTINCT CASE WHEN created_at >= :d1 THEN user_uid END) AS a1, "
"COUNT(DISTINCT CASE WHEN created_at >= :d7 THEN user_uid END) AS a7, "
"COUNT(DISTINCT CASE WHEN created_at >= :d30 THEN user_uid END) AS a30 "
f"FROM ({union})",
d1=d1,
d7=d7,
d30=d30,
)
for row in rows:
active_24h = row["a1"] or 0
active_7d = row["a7"] or 0
active_30d = row["a30"] or 0
signed_in_now = 0
if "sessions" in db.tables:
for row in db.query(
"SELECT COUNT(DISTINCT user_uid) AS n FROM sessions WHERE expires_at > :now AND deleted_at IS NULL",
now=now.isoformat(),
):
signed_in_now = row["n"] or 0
def _users_created_since(cutoff: str) -> int:
return (
db["users"].count(created_at={">=": cutoff}) if "users" in db.tables else 0
)
site = get_site_stats()
totals = {
"posts": db["posts"].count(deleted_at=None) if "posts" in db.tables else 0,
"comments": db["comments"].count(deleted_at=None)
if "comments" in db.tables
else 0,
"gists": site["total_gists"],
"projects": site["total_projects"],
"news": db["news"].count(deleted_at=None) if "news" in db.tables else 0,
}
top_authors = [
{"username": author.get("username", ""), "stars": author.get("stars", 0)}
for author in get_top_authors(top_n)
]
result = {
"total_members": site["total_members"],
"active_24h": active_24h,
"active_7d": active_7d,
"active_30d": active_30d,
"signed_in_now": signed_in_now,
"new_24h": _users_created_since(d1),
"new_7d": _users_created_since(d7),
"new_30d": _users_created_since(d30),
"posts_today": site["posts_today"],
"totals": totals,
"top_authors": top_authors,
"active_definition": (
"Active = created a post, comment, gist, or project within the window. "
"Automated/bot accounts are not separately flagged."
),
"signed_in_definition": (
"signed_in_now = distinct members holding an unexpired session cookie, i.e. who "
"logged in within the session lifetime (default 7 days, up to 30 with remember-me) "
"and have not logged out. It is NOT a real-time presence/online count - the platform "
"does not track per-request last-seen activity - so it is normally a large fraction "
"of recently active members. Do not describe it as 'currently online' or 'logged in "
"right now'."
),
}
_analytics_cache.set(cache_key, result)
return result
_gist_languages_cache = TTLCache(ttl=60, max_size=200)
def get_gist_languages() -> set[str]:
cached = _gist_languages_cache.get("codes")
if cached is not None:
return cached
codes: set[str] = set()
if "gists" in db.tables:
for row in db.query(
"SELECT DISTINCT language FROM gists WHERE deleted_at IS NULL"
):
language = row.get("language")
if language:
codes.add(language)
_gist_languages_cache.set("codes", codes)
return codes
+139
View File
@@ -0,0 +1,139 @@
# retoor <retoor@molodetz.nl>
from .core import datetime, db, get_table, timezone
def _add_usage(usage_table: str, user_uid: str, totals: dict) -> None:
if not user_uid or int(totals.get("calls") or 0) <= 0 or usage_table not in db.tables:
return
with db:
db.query(
f"INSERT INTO {usage_table} "
"(user_uid, calls, prompt_tokens, completion_tokens, total_tokens, cost_usd, "
"upstream_latency_ms, total_latency_ms, updated_at) "
"VALUES (:user_uid, :calls, :prompt, :completion, :total, :cost, :ulat, :tlat, :now) "
"ON CONFLICT(user_uid) DO UPDATE SET "
"calls = calls + excluded.calls, "
"prompt_tokens = prompt_tokens + excluded.prompt_tokens, "
"completion_tokens = completion_tokens + excluded.completion_tokens, "
"total_tokens = total_tokens + excluded.total_tokens, "
"cost_usd = cost_usd + excluded.cost_usd, "
"upstream_latency_ms = upstream_latency_ms + excluded.upstream_latency_ms, "
"total_latency_ms = total_latency_ms + excluded.total_latency_ms, "
"updated_at = excluded.updated_at",
user_uid=user_uid,
calls=int(totals.get("calls") or 0),
prompt=int(totals.get("prompt_tokens") or 0),
completion=int(totals.get("completion_tokens") or 0),
total=int(totals.get("total_tokens") or 0),
cost=float(totals.get("cost_usd") or 0.0),
ulat=float(totals.get("upstream_latency_ms") or 0.0),
tlat=float(totals.get("total_latency_ms") or 0.0),
now=datetime.now(timezone.utc).isoformat(),
)
def _get_usage(usage_table: str, user_uid: str) -> dict:
empty = {
"calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cost_usd": 0.0,
"upstream_latency_ms": 0.0,
"total_latency_ms": 0.0,
"avg_tokens": 0.0,
"avg_upstream_latency_ms": 0.0,
"avg_total_latency_ms": 0.0,
"avg_tokens_per_second": 0.0,
"avg_cost_usd": 0.0,
"updated_at": None,
}
if not user_uid or usage_table not in db.tables:
return empty
row = get_table(usage_table).find_one(user_uid=user_uid)
if not row:
return empty
calls = int(row.get("calls") or 0)
completion = int(row.get("completion_tokens") or 0)
total_tokens = int(row.get("total_tokens") or 0)
cost = float(row.get("cost_usd") or 0.0)
upstream_ms = float(row.get("upstream_latency_ms") or 0.0)
total_ms = float(row.get("total_latency_ms") or 0.0)
return {
"calls": calls,
"prompt_tokens": int(row.get("prompt_tokens") or 0),
"completion_tokens": completion,
"total_tokens": total_tokens,
"cost_usd": cost,
"upstream_latency_ms": upstream_ms,
"total_latency_ms": total_ms,
"avg_tokens": round(total_tokens / calls, 1) if calls else 0.0,
"avg_upstream_latency_ms": round(upstream_ms / calls, 1) if calls else 0.0,
"avg_total_latency_ms": round(total_ms / calls, 1) if calls else 0.0,
"avg_tokens_per_second": round(completion / (upstream_ms / 1000.0), 1)
if upstream_ms > 0
else 0.0,
"avg_cost_usd": (cost / calls) if calls else 0.0,
"updated_at": row.get("updated_at"),
}
def add_correction_usage(user_uid: str, totals: dict) -> None:
_add_usage("correction_usage", user_uid, totals)
def get_correction_usage(user_uid: str) -> dict:
return _get_usage("correction_usage", user_uid)
def add_modifier_usage(user_uid: str, totals: dict) -> None:
_add_usage("modifier_usage", user_uid, totals)
def get_modifier_usage(user_uid: str) -> dict:
return _get_usage("modifier_usage", user_uid)
NEWS_USAGE_KEY = "news"
def add_news_usage(totals: dict) -> None:
_add_usage("news_usage", NEWS_USAGE_KEY, totals)
def get_news_usage() -> dict:
return _get_usage("news_usage", NEWS_USAGE_KEY)
ISSUE_USAGE_KEY = "issues"
def add_issue_usage(totals: dict) -> None:
_add_usage("issue_usage", ISSUE_USAGE_KEY, totals)
def get_issue_usage() -> dict:
return _get_usage("issue_usage", ISSUE_USAGE_KEY)
SEO_USAGE_KEY = "seo_meta"
def add_seo_usage(totals: dict) -> None:
_add_usage("seo_usage", SEO_USAGE_KEY, totals)
def get_seo_usage() -> dict:
return _get_usage("seo_usage", SEO_USAGE_KEY)
AWARD_USAGE_KEY = "award"
def add_award_usage(totals: dict) -> None:
_add_usage("award_usage", AWARD_USAGE_KEY, totals)
def get_award_usage() -> dict:
return _get_usage("award_usage", AWARD_USAGE_KEY)
+128
View File
@@ -0,0 +1,128 @@
# retoor <retoor@molodetz.nl>
from .core import TTLCache, bump_cache_version, db, sync_local_cache
def get_users_by_uids(uids):
if not uids or "users" not in db.tables:
return {}
users = db["users"]
if "uid" not in users.columns:
return {}
seen = set()
unique = [u for u in uids if u not in seen and not seen.add(u)]
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
_admins_cache = TTLCache(ttl=300, max_size=4)
# The primary administrator must be an account that can actually authenticate, so scan a
# few of the earliest admins and skip any that are soft-deleted or deactivated.
PRIMARY_ADMIN_CANDIDATES = 50
def invalidate_admins_cache() -> None:
_admins_cache.clear()
bump_cache_version("admins")
def get_admin_uids():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("uids")
if cached is not None:
return list(cached)
if "users" not in db.tables:
return []
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
uids = [row["uid"] for row in rows]
_admins_cache.set("uids", uids)
return list(uids)
def set_user_timezone(user_uid: str, tz_name: str) -> None:
if "users" not in db.tables or not user_uid or not tz_name:
return
users = db["users"]
if not users.has_column("timezone"):
users.create_column_by_example("timezone", "")
current = users.find_one(uid=user_uid)
if current and current.get("timezone") == tz_name:
return
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
def set_last_seen(user_uid: str, iso: str) -> None:
if "users" not in db.tables or not user_uid or not iso:
return
users = db["users"]
if not users.has_column("last_seen"):
users.create_column_by_example("last_seen", "")
users.update({"uid": user_uid, "last_seen": iso}, ["uid"])
def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
if "users" not in db.tables:
return []
users = db["users"]
if "last_seen" not in users.columns:
return []
return list(
users.find(
last_seen={">=": cutoff_iso},
order_by=["username"],
_limit=limit,
)
)
def is_account_active(row) -> bool:
is_active = (row or {}).get("is_active")
return is_active is None or bool(is_active)
def _can_hold_primary_admin(row, tracks_active):
if row.get("deleted_at"):
return False
return not tracks_active or is_account_active(row)
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
if cached is not None:
return cached or None
if "users" not in db.tables:
return None
rows = list(
db.query(
"SELECT * FROM users WHERE role = 'Admin' "
"ORDER BY (created_at IS NULL OR created_at = ''), created_at ASC, id ASC "
"LIMIT :cap",
cap=PRIMARY_ADMIN_CANDIDATES,
)
)
tracks_active = "is_active" in db["users"].columns
primary = next(
(row["uid"] for row in rows if _can_hold_primary_admin(row, tracks_active)),
None,
)
_admins_cache.set("primary", primary or "")
return primary
def search_users_by_username(q, *, exclude_uid=None, limit=10):
if not q or "users" not in db.tables:
return []
if exclude_uid is not None:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q AND uid != :me LIMIT :limit",
q=f"%{q}%",
me=exclude_uid,
limit=limit,
)
else:
rows = db.query(
"SELECT uid, username FROM users WHERE username LIKE :q LIMIT :limit",
q=f"%{q}%",
limit=limit,
)
return [{"uid": r["uid"], "username": r["username"]} for r in rows]
+95
View File
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
"""
Generic FastAPI dependency that accepts JSON or form-encoded data,
validated against a Pydantic model.
"""
import json
import logging
from typing import Any, TypeVar, get_origin
from fastapi import HTTPException, Request
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, ValidationError
from starlette.datastructures import FormData
logger = logging.getLogger(__name__)
_TModel = TypeVar("_TModel", bound=BaseModel)
# Container origins recognised as sequence fields that may receive
# multiple values from form data.
_SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset})
def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
"""Convert FormData to a dict suitable for Pydantic validation.
* Sequence-typed model fields collect every submitted value via
``getlist()``; a lone empty string is dropped (browsers emit empty
hidden inputs by default).
* Scalar fields use ``get()`` (the last value).
* Fields absent from the form are omitted so that Pydantic applies
the model default.
"""
body: dict[str, Any] = {}
for field_name, field_info in model.model_fields.items():
origin = get_origin(field_info.annotation)
if origin in _SEQUENCE_ORIGINS:
values = form.getlist(field_name)
if not values:
continue
if values == [""]:
continue
body[field_name] = [v for v in values if v != ""] or []
else:
value = form.get(field_name)
if value is not None:
body[field_name] = value
return body
class _JsonOrForm:
"""Internal callable that parses JSON or form data and validates."""
def __init__(self, model: type[BaseModel]):
self.model = model
async def __call__(self, request: Request) -> Any:
content_type = request.headers.get("content-type", "")
body: Any = None
try:
if "application/json" in content_type:
try:
body = await request.json()
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
logger.debug("JSON parse failed: %s", exc)
raise HTTPException(status_code=400, detail="Invalid JSON body")
if not isinstance(body, dict):
raise HTTPException(
status_code=400, detail="JSON body must be an object"
)
return self.model.model_validate(body)
# Default: form-encoded (multipart or url-encoded)
try:
form = await request.form()
except Exception as exc:
logger.debug("Form parse failed: %s", exc)
raise HTTPException(
status_code=400, detail="Could not parse form data"
)
body = _formdata_to_dict(form, self.model)
return self.model.model_validate(body)
except ValidationError as exc:
raise RequestValidationError(errors=exc.errors(), body=body)
def json_or_form(model: type[_TModel]) -> _JsonOrForm:
"""Dependency factory: accept JSON or form-encoded data for a Pydantic model.
Usage:
@router.post("/create")
async def create(data: Annotated[PostForm, Depends(json_or_form(PostForm))]):
...
"""
return _JsonOrForm(model)
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
from ._shared import (
BOOKMARK_TARGETS,
COMMENT_TARGETS,
GIST_LANGUAGES,
NON_BODY_ENDPOINTS,
PROJECT_TYPES,
REACTION_TARGETS,
ROLE_LABELS,
SERVICE_ACTIONS,
VOTE_TARGETS,
endpoint,
field,
)
from .groups import ORDERED_GROUPS as API_GROUPS
from .negotiation import _apply_negotiated_responses
from .services_group import build_services_group
from .render import api_doc_pages, get_group, render_group, _substitute
_apply_negotiated_responses(API_GROUPS)
__all__ = [
"API_GROUPS",
"build_services_group",
"get_group",
"api_doc_pages",
"render_group",
"_substitute",
"endpoint",
"field",
"ROLE_LABELS",
"NON_BODY_ENDPOINTS",
"VOTE_TARGETS",
"REACTION_TARGETS",
"BOOKMARK_TARGETS",
"COMMENT_TARGETS",
"PROJECT_TYPES",
"GIST_LANGUAGES",
"SERVICE_ACTIONS",
]
+98
View File
@@ -0,0 +1,98 @@
# retoor <retoor@molodetz.nl>
VOTE_TARGETS = ["post", "comment", "gist", "project", "quiz"]
REACTION_TARGETS = ["post", "comment", "gist", "project", "quiz"]
BOOKMARK_TARGETS = ["post", "gist", "project", "news", "quiz"]
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist", "quiz"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = [
"python",
"javascript",
"typescript",
"html",
"css",
"c",
"cpp",
"java",
"go",
"rust",
"sql",
"bash",
"json",
"markdown",
"plaintext",
]
SERVICE_ACTIONS = [
("start", "Start the service"),
("stop", "Stop the service"),
("run", "Trigger a single run now"),
("clear-logs", "Clear the in-memory log buffer"),
]
def field(
name,
location,
type="string",
required=False,
example="",
description="",
options=None,
):
spec = {
"name": name,
"location": location,
"type": type,
"required": required,
"example": example,
"description": description,
}
if options:
spec["options"] = list(options)
return spec
ROLE_LABELS = {"public": "Public", "user": "Member", "admin": "Admin"}
def endpoint(
id,
method,
path,
title,
summary,
auth="user",
ajax=False,
encoding="none",
interactive=True,
destructive=False,
params=None,
notes=None,
sample_response=None,
negotiation=None,
):
return {
"id": id,
"method": method,
"path": path,
"title": title,
"summary": summary,
"auth": auth,
"min_role": ROLE_LABELS.get(auth, auth.title()),
"ajax": ajax,
"encoding": encoding,
"interactive": interactive,
"destructive": destructive,
"params": params or [],
"notes": notes or [],
"sample_response": sample_response,
"negotiation": negotiation,
}
NON_BODY_ENDPOINTS = {
"avatar",
"gateway-passthrough",
"notifications-open",
"admin-bots-frame",
}

Some files were not shown because too many files have changed in this diff Show More