Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67caf3f479 | ||
|
|
0d560c3d18 | ||
|
|
63125e7aa5 | ||
|
|
df03febff0 | ||
|
|
c8be71219d | ||
|
|
12285ecba3 | ||
|
|
caf59108a7 | ||
|
|
cd37d19d8d | ||
|
|
32d4ee6e69 | ||
|
|
afb954deaa | ||
|
|
c60d89b304 | ||
|
|
6941c51560 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: docs-maintainer
|
||||
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.
|
||||
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.
|
||||
tools: Read, Grep, Glob, Edit, Write, Bash
|
||||
model: inherit
|
||||
color: blue
|
||||
@@ -36,26 +36,23 @@ Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX**
|
||||
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 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`).**
|
||||
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.
|
||||
|
||||
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. 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`.
|
||||
- `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.
|
||||
- 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` 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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **agents-md**: `AGENTS.md` has a domain section for every mechanic; `CLAUDE.md` only for new rules.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 the relevant nested `CLAUDE.md` mention the feature.
|
||||
- `README.md` and `AGENTS.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.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ 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 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.
|
||||
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.
|
||||
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.
|
||||
@@ -26,7 +26,7 @@ Default to **PLAN** mode. Investigate the area, then return a layer-by-layer imp
|
||||
|
||||
## 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.
|
||||
1. **Codebase pass (always).** Read the router, template, matching tests, and the relevant `CLAUDE.md`/`AGENTS.md` section; 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.
|
||||
@@ -41,7 +41,7 @@ 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 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`.
|
||||
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 (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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: style-maintainer
|
||||
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.
|
||||
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.
|
||||
tools: Read, Grep, Glob, Edit, Write, Bash
|
||||
model: inherit
|
||||
color: orange
|
||||
@@ -36,7 +36,7 @@ Default to **REPORT** mode: record findings, do NOT modify files. Apply **FIX**
|
||||
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 (root plus every nested per-subsystem `CLAUDE.md`) coding rules across all source.
|
||||
Enforce the explicit CLAUDE.md and AGENTS.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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
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.
|
||||
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.
|
||||
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 nested `CLAUDE.md` for the subsystem (e.g. `devplacepy/services/devii/CLAUDE.md`), plus the relevant cross-cutting part of the root `CLAUDE.md`.
|
||||
2. Read the matching domain section in `AGENTS.md` (the long-form companion) and the relevant part of `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 the nested CLAUDE.md).
|
||||
- Key invariants and gotchas (pull these from AGENTS.md).
|
||||
- The fan-out: which of the nine feature layers exist for it.
|
||||
|
||||
Do not modify anything.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
description: Run the DevPlace maintenance agent fleet (12 quality dimensions) in check or fix mode, optionally scoped to changed files or a subset.
|
||||
description: Run the DevPlace maintenance agent fleet (10 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 twelve 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 ten independent quality dimensions across the `devplacepy/` package and `tests/`.
|
||||
|
||||
## Dimension to subagent map
|
||||
| Dimension | Subagent | Enforces |
|
||||
|-----------|----------|----------|
|
||||
| style | `style-maintainer` | CLAUDE.md (root/nested) coding rules (context-aware names, em-dash, typing, pathlib, headers) |
|
||||
| style | `style-maintainer` | CLAUDE.md/AGENTS.md 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,17 +18,15 @@ 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, background, locust**.
|
||||
The canonical run order is: **style, dry, security, audit, devii, seo, frontend, fanout, docs, test**.
|
||||
|
||||
## 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 twelve.
|
||||
- **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.
|
||||
|
||||
## Execute
|
||||
1. Resolve the dimension list and mode from the arguments above.
|
||||
|
||||
@@ -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 `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.
|
||||
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"`.
|
||||
|
||||
@@ -35,7 +35,7 @@ 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) + the relevant nested CLAUDE.md (mechanics) + the root CLAUDE.md (only for a genuinely new architectural rule).',
|
||||
'9. README.md (product) + AGENTS.md (mechanics) + CLAUDE.md (only for a genuinely new architectural rule).',
|
||||
].join('\n')
|
||||
|
||||
const TESTS = [
|
||||
@@ -175,7 +175,7 @@ const LIVE_SCHEMA = {
|
||||
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 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.`,
|
||||
`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.`,
|
||||
{ agentType: 'Explore', label: 'understand', phase: 'Understand', schema: MAP_SCHEMA }
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
export const meta = {
|
||||
name: 'fleet',
|
||||
description: 'DevPlace maintenance fleet: 12 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
|
||||
description: 'DevPlace maintenance fleet: 10 dimension subagents scan in parallel, then every finding is adversarially verified against source before it is reported',
|
||||
phases: [
|
||||
{ title: 'Review', detail: '12 dimension subagents scan devplacepy/ and tests/ in parallel' },
|
||||
{ title: 'Review', detail: '10 dimension subagents scan devplacepy/ and tests/ in parallel' },
|
||||
{ title: 'Verify', detail: 'adversarially refute each candidate finding against the actual source' },
|
||||
],
|
||||
}
|
||||
@@ -19,8 +19,6 @@ 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 = {
|
||||
@@ -88,7 +86,7 @@ function reportPrompt(dimension) {
|
||||
|
||||
function verifyPrompt(dimension, finding) {
|
||||
return (
|
||||
`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 ` +
|
||||
`Adversarially verify a candidate "${dimension}" finding. 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. 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 ` +
|
||||
@@ -118,6 +116,7 @@ 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,
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
// 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 (<...>). 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,
|
||||
}
|
||||
@@ -32,7 +32,7 @@ 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 + devplacepy/services/jobs/CLAUDE.md - document the new job kind.',
|
||||
'9. README.md + AGENTS.md - document the new job kind.',
|
||||
].join('\n')
|
||||
|
||||
const TESTS = [
|
||||
|
||||
@@ -19,9 +19,6 @@ 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 = {
|
||||
@@ -105,8 +102,8 @@ const reviewed = await pipeline(
|
||||
parallel(
|
||||
((review && review.findings) || []).map((finding) => () =>
|
||||
agent(
|
||||
`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 }
|
||||
`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 }
|
||||
).then((verdict) => ({ ...finding, dimension: dimension.key, verdict }))
|
||||
)
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,9 +19,9 @@ Open `http://localhost:10500`.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Python 3.12+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Templates | Jinja2 (server-side rendered) |
|
||||
| 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 |
|
||||
| Frontend | Pure ES6 JavaScript, one class per file |
|
||||
| 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). 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. |
|
||||
@@ -35,10 +35,10 @@ Open `http://localhost:10500`.
|
||||
devplacepy/
|
||||
main.py # FastAPI app, router registration
|
||||
config.py # Settings from env vars + .env
|
||||
database/ # dataset connection, index creation (package)
|
||||
database.py # dataset connection, index creation
|
||||
templating.py # Shared Jinja2 environment + globals
|
||||
avatar.py # Multiavatar generation, URL builder
|
||||
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
||||
utils.py # Password hashing, session mgmt, time_ago, notification hook
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
@@ -59,20 +59,20 @@ devplacepy/
|
||||
| `/posts` | Post detail, creation |
|
||||
| `/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` | 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) |
|
||||
| `/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 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`) |
|
||||
| `/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 (scoped by project visibility - instances attached to another administrator's hidden project are excluded). 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, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
|
||||
| `/profile` | Profile view, editing, and a public **Media** tab (`?tab=media`) showing every attachment a user uploaded, newest first |
|
||||
| `/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/` |
|
||||
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
|
||||
| `/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). 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. An opened conversation loads its 500 most recent messages; older history is retained in the database. 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 and YouTube 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 `POST /messages/send` form remains as a no-JavaScript fallback |
|
||||
| `/votes` | Upvote/downvote on posts, comments, projects |
|
||||
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
@@ -99,7 +99,7 @@ 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 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.
|
||||
- **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, 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`.
|
||||
@@ -141,11 +141,11 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
|
||||
- **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/`). 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.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
|
||||
|
||||
## 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`.
|
||||
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 `PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`, `PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`, and `PRAVDA_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
|
||||
|
||||
@@ -166,9 +166,6 @@ 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 |
|
||||
|
||||
### Runtime settings
|
||||
|
||||
@@ -195,7 +192,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/`) so they work everywhere with no per-route changes:
|
||||
(`utils.py`) so they work everywhere with no per-route changes:
|
||||
|
||||
- **API key** - `X-API-KEY: <key>`
|
||||
- **Bearer** - `Authorization: Bearer <key>`
|
||||
@@ -221,7 +218,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/` 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.py` 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
|
||||
@@ -361,7 +358,7 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
`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.
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -377,9 +374,7 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
`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).
|
||||
|
||||
@@ -450,7 +445,7 @@ Configuration on the Services tab:
|
||||
| `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` / `45` | Idle pause window a bot takes after each action |
|
||||
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `30` | 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 |
|
||||
@@ -586,9 +581,9 @@ Configuration on the Services tab:
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| `devii_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | OpenAI-compatible reasoning endpoint (defaults to the internal gateway) |
|
||||
| `devii_ai_url` | `https://openai.app.molodetz.nl/v1/chat/completions` | OpenAI-compatible reasoning endpoint |
|
||||
| `devii_ai_model` | `molodetz` | Model name |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`), then the gateway internal key | AI API key |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_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 |
|
||||
@@ -721,7 +716,7 @@ installable Progressive Web App. Push uses only standard libraries (`cryptograph
|
||||
|
||||
### Events
|
||||
|
||||
Every event flows through a single funnel - `create_notification()` in `utils/` -
|
||||
Every event flows through a single funnel - `create_notification()` in `utils.py` -
|
||||
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
|
||||
@@ -792,8 +787,8 @@ every page load. `PushManager.js` owns registration, subscription, and the opt-i
|
||||
|
||||
### PWA
|
||||
|
||||
`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
|
||||
`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
|
||||
network-first strategy for navigations and falls back to `static/offline.html` when
|
||||
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
|
||||
|
||||
@@ -802,6 +797,7 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
|
||||
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
|
||||
| `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 |
|
||||
@@ -847,7 +843,7 @@ Two background services bridge persisted state onto the bus so the interface upd
|
||||
|
||||
## Testing
|
||||
|
||||
- **1959 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
|
||||
- **932 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`
|
||||
@@ -913,12 +909,12 @@ 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 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.
|
||||
- **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.
|
||||
- **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 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.
|
||||
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.
|
||||
|
||||
### nginx specifics
|
||||
|
||||
@@ -945,7 +941,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 $(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`.
|
||||
`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`.
|
||||
|
||||
### Multi-worker safety
|
||||
|
||||
@@ -968,7 +964,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 the relevant nested `CLAUDE.md` and `README.md` if new conventions were introduced
|
||||
5. Update `AGENTS.md` and `README.md` if new conventions were introduced
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ from devplacepy.cli.jobs import (
|
||||
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,
|
||||
@@ -68,9 +65,6 @@ __all__ = [
|
||||
"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",
|
||||
|
||||
@@ -210,104 +210,6 @@ def cmd_deepsearch_clear(args):
|
||||
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
|
||||
import json
|
||||
|
||||
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")
|
||||
@@ -363,21 +265,3 @@ def register_jobs(subparsers):
|
||||
"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)
|
||||
|
||||
@@ -26,10 +26,6 @@ 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"
|
||||
@@ -47,13 +43,6 @@ SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
|
||||
PORT = 10500
|
||||
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
|
||||
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_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"))
|
||||
|
||||
@@ -100,10 +89,6 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"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,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
|
||||
REACTION_EMOJI = [
|
||||
"\U0001f44d",
|
||||
|
||||
@@ -19,7 +19,6 @@ from devplacepy.database import (
|
||||
get_blocked_uids,
|
||||
get_poll_for_post,
|
||||
update_target_stars,
|
||||
clear_user_stars,
|
||||
get_target_owner_uid,
|
||||
resolve_object_url,
|
||||
soft_delete,
|
||||
@@ -39,7 +38,6 @@ from devplacepy.utils import (
|
||||
create_notification,
|
||||
create_mention_notifications,
|
||||
is_admin,
|
||||
is_primary_admin,
|
||||
XP_COMMENT,
|
||||
XP_UPVOTE,
|
||||
)
|
||||
@@ -78,45 +76,6 @@ def can_view_project(project: dict | None, user: dict | None) -> bool:
|
||||
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 canonical_redirect(
|
||||
area: str, item: dict, requested: str
|
||||
) -> RedirectResponse | None:
|
||||
@@ -162,10 +121,6 @@ def create_content_item(
|
||||
**fields,
|
||||
}
|
||||
)
|
||||
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)
|
||||
@@ -254,8 +209,6 @@ 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"]:
|
||||
@@ -617,11 +570,9 @@ def delete_content_item(
|
||||
soft_delete_engagement("comment", comment_uids, actor)
|
||||
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(
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
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}}, 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.
|
||||
|
||||
`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).
|
||||
|
||||
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)`. 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`.
|
||||
- **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 |
|
||||
| `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`). 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.
|
||||
@@ -3,7 +3,7 @@
|
||||
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 .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, search_users_by_username
|
||||
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, get_primary_admin_uid, 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, 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
|
||||
@@ -17,7 +17,7 @@ from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICAT
|
||||
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 .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
|
||||
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
|
||||
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_deleted_media
|
||||
@@ -66,8 +66,6 @@ __all__ = [
|
||||
"invalidate_admins_cache",
|
||||
"get_admin_uids",
|
||||
"set_user_timezone",
|
||||
"set_last_seen",
|
||||
"get_online_users",
|
||||
"get_primary_admin_uid",
|
||||
"search_users_by_username",
|
||||
"_relations_cache",
|
||||
@@ -191,7 +189,6 @@ __all__ = [
|
||||
"get_leaderboard",
|
||||
"get_user_rank",
|
||||
"get_user_stars",
|
||||
"clear_user_stars",
|
||||
"update_target_stars",
|
||||
"soft_delete_engagement",
|
||||
"delete_engagement",
|
||||
|
||||
@@ -45,7 +45,6 @@ def get_follow_list(
|
||||
"uid": person["uid"],
|
||||
"username": person["username"],
|
||||
"bio": (person.get("bio") or "")[:140],
|
||||
"last_seen": person.get("last_seen"),
|
||||
"followed_at": row.get("created_at"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,10 +16,7 @@ VOTABLE_TARGETS: dict[str, str] = {
|
||||
STAR_TARGETS: set[str] = {"post", "project", "gist"}
|
||||
|
||||
|
||||
_authors_cache = TTLCache(ttl=15, max_size=200)
|
||||
|
||||
|
||||
_stars_cache = TTLCache(ttl=15, max_size=2000)
|
||||
_authors_cache = TTLCache(ttl=300, max_size=200)
|
||||
|
||||
|
||||
def _ranked_authors() -> list:
|
||||
@@ -87,14 +84,7 @@ 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
|
||||
if "votes" not in db.tables:
|
||||
return 0
|
||||
target_union = " UNION ALL ".join(
|
||||
@@ -104,17 +94,14 @@ def get_user_stars(user_uid: str) -> int:
|
||||
)
|
||||
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
|
||||
return row["s"] or 0
|
||||
return 0
|
||||
|
||||
|
||||
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
||||
@@ -123,6 +110,7 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
|
||||
return
|
||||
if target_type in STAR_TARGETS:
|
||||
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
||||
_authors_cache.clear()
|
||||
|
||||
|
||||
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
|
||||
|
||||
@@ -36,12 +36,9 @@ def init_db():
|
||||
_index(db, "users", "idx_users_email", ["email"])
|
||||
_index(db, "users", "idx_users_api_key", ["api_key"])
|
||||
_index(db, "users", "idx_users_role", ["role", "created_at"])
|
||||
_index(db, "users", "idx_users_last_seen", ["last_seen"])
|
||||
_index(db, "users", "idx_users_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
|
||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||
_index(db, "posts", "idx_posts_slug", ["slug"])
|
||||
if "posts" in tables:
|
||||
posts_table = get_table("posts")
|
||||
if not posts_table.has_column("tags"):
|
||||
@@ -122,15 +119,6 @@ def init_db():
|
||||
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
|
||||
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
|
||||
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
|
||||
_index(
|
||||
db, "messages", "idx_messages_conversation", ["sender_uid", "receiver_uid"]
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"messages",
|
||||
"idx_messages_conversation_rev",
|
||||
["receiver_uid", "sender_uid"],
|
||||
)
|
||||
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
@@ -151,7 +139,6 @@ def init_db():
|
||||
projects.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "projects", "idx_projects_user", ["user_uid"])
|
||||
_index(db, "projects", "idx_projects_slug", ["slug"])
|
||||
_index(db, "projects", "idx_projects_private", ["is_private"])
|
||||
_index(db, "projects", "idx_projects_stars", ["stars"])
|
||||
_index(db, "projects", "idx_projects_type", ["project_type"])
|
||||
@@ -182,7 +169,6 @@ def init_db():
|
||||
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
|
||||
)
|
||||
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
||||
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
|
||||
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
|
||||
_index(db, "follows", "idx_follows_following", ["following_uid"])
|
||||
user_relations = get_table("user_relations")
|
||||
@@ -202,7 +188,6 @@ def init_db():
|
||||
_index(db, "password_resets", "idx_password_resets_token", ["token"])
|
||||
_index(db, "gists", "idx_gists_user_uid", ["user_uid"])
|
||||
_index(db, "gists", "idx_gists_language", ["language"])
|
||||
_index(db, "gists", "idx_gists_slug", ["slug"])
|
||||
attachments = get_table("attachments")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -258,7 +243,6 @@ def init_db():
|
||||
db["site_settings"].insert(
|
||||
{"uid": f"default_{key}", "key": key, "value": value}
|
||||
)
|
||||
_index(db, "site_settings", "idx_site_settings_key", ["key"])
|
||||
|
||||
news = get_table("news")
|
||||
for column, example in (
|
||||
@@ -287,7 +271,6 @@ def init_db():
|
||||
news.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "news", "idx_news_external_id", ["external_id"])
|
||||
_index(db, "news", "idx_news_slug", ["slug"])
|
||||
_index(db, "news", "idx_news_synced_at", ["synced_at"])
|
||||
_index(db, "news", "idx_news_status", ["status"])
|
||||
_index(db, "news", "idx_news_featured", ["featured"])
|
||||
@@ -473,8 +456,6 @@ def init_db():
|
||||
instances.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "instances", "idx_instances_project", ["project_uid"])
|
||||
_index(db, "instances", "idx_instances_slug", ["slug"])
|
||||
_index(db, "instances", "idx_instances_name", ["name"])
|
||||
_index(db, "instances", "idx_instances_state", ["desired_state", "status"])
|
||||
_index(db, "instances", "idx_instances_container", ["container_id"])
|
||||
_index(db, "instances", "idx_instances_ingress", ["ingress_slug"])
|
||||
@@ -809,108 +790,6 @@ def init_db():
|
||||
deepsearch_url_cache.create_column_by_example(column, example)
|
||||
_index(db, "deepsearch_url_cache", "idx_deepsearch_url_cache_hash", ["url_hash"])
|
||||
|
||||
isslop_analyses = get_table("isslop_analyses")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("source_url", ""),
|
||||
("source_kind", ""),
|
||||
("status", ""),
|
||||
("created_at", ""),
|
||||
("finished_at", ""),
|
||||
("content_hash", ""),
|
||||
("grade", ""),
|
||||
("slop_score", 0.0),
|
||||
("origin_score", 0.0),
|
||||
("quality_deficit_score", 0.0),
|
||||
("human_percent", 0.0),
|
||||
("ai_percent", 0.0),
|
||||
("category", ""),
|
||||
("confidence", ""),
|
||||
("files_total", 0),
|
||||
("files_analyzed", 0),
|
||||
("error_message_text", ""),
|
||||
("detected_builder", ""),
|
||||
("dom_slop_score", 0.0),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not isslop_analyses.has_column(column):
|
||||
isslop_analyses.create_column_by_example(column, example)
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_owner", ["owner_kind", "owner_id", "created_at"])
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_status", ["status"])
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_hash", ["content_hash"])
|
||||
|
||||
isslop_events = get_table("isslop_events")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("seq", 0),
|
||||
("kind", ""),
|
||||
("message", ""),
|
||||
("payload", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not isslop_events.has_column(column):
|
||||
isslop_events.create_column_by_example(column, example)
|
||||
_index(db, "isslop_events", "idx_isslop_events_analysis", ["analysis_uid", "seq"])
|
||||
|
||||
isslop_file_results = get_table("isslop_file_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("path", ""),
|
||||
("language", ""),
|
||||
("lines", 0),
|
||||
("origin_score", 0.0),
|
||||
("quality_deficit_score", 0.0),
|
||||
("category", ""),
|
||||
("signals", ""),
|
||||
("source", ""),
|
||||
):
|
||||
if not isslop_file_results.has_column(column):
|
||||
isslop_file_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_file_results", "idx_isslop_file_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_image_results = get_table("isslop_image_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("path", ""),
|
||||
("ai_probability", 0.0),
|
||||
("grade", ""),
|
||||
("verdict", ""),
|
||||
("image_kind", ""),
|
||||
("tells", ""),
|
||||
("description", ""),
|
||||
("thumb", ""),
|
||||
):
|
||||
if not isslop_image_results.has_column(column):
|
||||
isslop_image_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_image_results", "idx_isslop_image_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_dom_results = get_table("isslop_dom_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("url", ""),
|
||||
("detected_builder", ""),
|
||||
("signal_count", 0),
|
||||
("screenshot", ""),
|
||||
("signals", ""),
|
||||
):
|
||||
if not isslop_dom_results.has_column(column):
|
||||
isslop_dom_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_dom_results", "idx_isslop_dom_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_reports = get_table("isslop_reports")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("markdown", ""),
|
||||
("model_used", ""),
|
||||
("generated_at", ""),
|
||||
):
|
||||
if not isslop_reports.has_column(column):
|
||||
isslop_reports.create_column_by_example(column, example)
|
||||
_index(db, "isslop_reports", "idx_isslop_reports_analysis", ["analysis_uid"], unique=True)
|
||||
|
||||
game_farms = get_table("game_farms")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -1238,8 +1117,6 @@ def backfill_api_keys() -> int:
|
||||
users.create_column_by_example("timezone", "")
|
||||
if not users.has_column("avatar_seed"):
|
||||
users.create_column_by_example("avatar_seed", "")
|
||||
if not users.has_column("last_seen"):
|
||||
users.create_column_by_example("last_seen", "")
|
||||
with db:
|
||||
db.query(
|
||||
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
|
||||
|
||||
@@ -35,7 +35,6 @@ SOFT_DELETE_TABLES = [
|
||||
"notification_preferences",
|
||||
"deepsearch_sessions",
|
||||
"deepsearch_messages",
|
||||
"isslop_analyses",
|
||||
"devrant_tokens",
|
||||
"access_tokens",
|
||||
"email_accounts",
|
||||
|
||||
@@ -47,30 +47,6 @@ def set_user_timezone(user_uid: str, tz_name: str) -> None:
|
||||
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 get_primary_admin_uid():
|
||||
sync_local_cache("admins", _admins_cache)
|
||||
cached = _admins_cache.get("primary")
|
||||
|
||||
@@ -458,7 +458,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
|
||||
@@ -22,9 +22,8 @@ distinguish the form type).
|
||||
The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
|
||||
and return a `302` redirect (or the JSON envelope for JSON callers).
|
||||
|
||||
**Sign-up requires a unique `username` and `email`** plus a `confirm_password` that matches the
|
||||
password; **you log in with your `email` and password**. JSON callers receive validation errors
|
||||
as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
|
||||
**Sign-up requires a valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
|
||||
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@@ -46,10 +45,10 @@ as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
|
||||
encoding="form",
|
||||
destructive=False,
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Username, 3-32 characters (letters, numbers, hyphens, underscores)."),
|
||||
field("email", "form", "string", True, "alice@example.com", "Email address; must be unique and contain an @."),
|
||||
field("username", "form", "string", True, "alice", "Username, 3-20 characters."),
|
||||
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
|
||||
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
|
||||
field("g-recaptcha-response", "form", "string", False, "", "reCAPTCHA token when enabled."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
@@ -69,13 +68,12 @@ as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
|
||||
method="POST",
|
||||
path="/auth/login",
|
||||
title="Log in",
|
||||
summary="Authenticate with email and password. Sets the session cookie.",
|
||||
summary="Authenticate with username and password. Sets the session cookie.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
|
||||
field("username", "form", "string", True, "alice", "Your username."),
|
||||
field("password", "form", "string", True, "mysecret", "Your password."),
|
||||
field("remember_me", "form", "string", False, "on", "Send 'on' to extend the session to the remember-me lifetime."),
|
||||
field("next", "form", "string", False, "", "Redirect target after login."),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,17 +9,9 @@ GROUP = {
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
Run supervised container instances for a project. There is no in-app image building: every instance
|
||||
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
|
||||
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
|
||||
state; a single reconciler converges containers to it.
|
||||
|
||||
Containers are additionally **isolated per user**. The primary administrator (the first Admin account)
|
||||
sees and manages every instance, including those attached to private projects. Any other administrator
|
||||
sees instances on public projects plus their own; instances attached to another user's private project
|
||||
are invisible. Managing an instance (edit, lifecycle, exec, terminal, sync, delete, schedules) is
|
||||
restricted to the instance owner (its creator or the owner of its project) and the primary
|
||||
administrator; a non-owner administrator receives `403` on mutations and a view-only detail page.
|
||||
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
|
||||
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
|
||||
Mutations flip desired state; a single reconciler converges containers to it.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@@ -27,7 +19,7 @@ administrator; a non-owner administrator receives `403` on mutations and a view-
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers",
|
||||
title="Container manager page",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator when the project is another user's private project (the primary administrator always has access).",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
@@ -46,7 +38,7 @@ administrator; a non-owner administrator receives `403` on mutations and a view-
|
||||
method="GET",
|
||||
path="/admin/containers",
|
||||
title="Admin containers list",
|
||||
summary="The admin Containers section, scoped per viewer: the primary administrator sees every instance; other administrators see instances on public projects plus their own. Rows the viewer cannot manage are view-only, and mutations on them return 403.",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
@@ -55,7 +47,7 @@ administrator; a non-owner administrator receives `403` on mutations and a view-
|
||||
method="GET",
|
||||
path="/admin/containers/data",
|
||||
title="Admin containers list data",
|
||||
summary="JSON of the viewer-visible instances (decorated with project title/slug and a per-row can_manage flag) for polling.",
|
||||
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"instances": [
|
||||
@@ -126,7 +118,7 @@ administrator; a non-owner administrator receives `403` on mutations and a view-
|
||||
"python app.py",
|
||||
"Optional boot command.",
|
||||
),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field(
|
||||
@@ -486,7 +478,7 @@ administrator; a non-owner administrator receives `403` on mutations and a view-
|
||||
params=[
|
||||
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "containers",
|
||||
"title": "Container Manager",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
Run supervised container instances for a project. There is no in-app image building: every instance
|
||||
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
|
||||
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
|
||||
state; a single reconciler converges containers to it.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="containers-page",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers",
|
||||
title="Container manager page",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-index",
|
||||
method="GET",
|
||||
path="/admin/containers",
|
||||
title="Admin containers list",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-data",
|
||||
method="GET",
|
||||
path="/admin/containers/data",
|
||||
title="Admin containers list data",
|
||||
summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
|
||||
auth="admin",
|
||||
sample_response={
|
||||
"instances": [
|
||||
{
|
||||
"uid": "INSTANCE_UID",
|
||||
"name": "staging",
|
||||
"status": "running",
|
||||
"project_slug": "PROJECT_SLUG",
|
||||
"project_title": "My Project",
|
||||
"ingress_slug": "my-service",
|
||||
"restart_policy": "always",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-instance",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}",
|
||||
title="Instance detail page",
|
||||
summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit-page",
|
||||
method="GET",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Edit instance page",
|
||||
summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-create-instance",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances",
|
||||
title="Create an instance",
|
||||
summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field(
|
||||
"boot_command",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"python app.py",
|
||||
"Optional boot command.",
|
||||
),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field(
|
||||
"env",
|
||||
"form",
|
||||
"textarea",
|
||||
False,
|
||||
"KEY=VALUE",
|
||||
"Env vars, one KEY=VALUE per line.",
|
||||
),
|
||||
field(
|
||||
"ports",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"80",
|
||||
"Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
|
||||
),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field(
|
||||
"mem_limit", "form", "string", False, "512m", "Memory limit."
|
||||
),
|
||||
field(
|
||||
"restart_policy",
|
||||
"form",
|
||||
"enum",
|
||||
False,
|
||||
"never",
|
||||
"Restart policy.",
|
||||
["never", "always", "on-failure", "unless-stopped"],
|
||||
),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field(
|
||||
"ingress_slug",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"my-service",
|
||||
"Publish at /p/<slug> (optional).",
|
||||
),
|
||||
field(
|
||||
"ingress_port",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"8899",
|
||||
"Container port to publish (must be a mapped port).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-ingress",
|
||||
method="GET",
|
||||
path="/p/{slug}",
|
||||
title="Container ingress proxy",
|
||||
summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"my-service",
|
||||
"The instance's ingress_slug.",
|
||||
)
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-action",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/{action}",
|
||||
title="Instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"path",
|
||||
"enum",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action.",
|
||||
["start", "stop", "restart", "pause", "resume"],
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-logs",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/logs",
|
||||
title="Instance logs",
|
||||
summary="Recent docker logs of a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field("tail", "query", "integer", False, "200", "Number of lines."),
|
||||
],
|
||||
sample_response={"logs": "..."},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-sync",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/sync",
|
||||
title="Sync workspace",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/delete",
|
||||
title="Delete instance",
|
||||
summary="Remove a container instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-exec",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/exec",
|
||||
title="Exec a command",
|
||||
summary="Run a one-shot command inside a running instance and return its output.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"command",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"ls -la /app",
|
||||
"Shell command to run (via /bin/sh -c).",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-data",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}",
|
||||
title="Instance detail data",
|
||||
summary="Return the full instance row plus runtime info as JSON.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-metrics",
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/metrics",
|
||||
title="Instance metrics",
|
||||
summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
|
||||
auth="admin",
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
],
|
||||
sample_response={"metrics": [], "stats": {}},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedules",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules",
|
||||
title="Create a schedule",
|
||||
summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"action",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"start",
|
||||
"Lifecycle action to run on schedule (start, stop, restart).",
|
||||
),
|
||||
field(
|
||||
"kind",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"cron",
|
||||
"Schedule kind: cron, once, interval, or delay.",
|
||||
),
|
||||
field(
|
||||
"cron",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"0 * * * *",
|
||||
"Cron expression (when kind is cron).",
|
||||
),
|
||||
field(
|
||||
"run_at",
|
||||
"form",
|
||||
"string",
|
||||
False,
|
||||
"2026-01-01T00:00:00",
|
||||
"ISO timestamp for a one-time run (when kind is once).",
|
||||
),
|
||||
field(
|
||||
"delay_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"60",
|
||||
"Seconds to wait before a single run (when kind is delay).",
|
||||
),
|
||||
field(
|
||||
"every_seconds",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"300",
|
||||
"Interval in seconds between runs (when kind is interval).",
|
||||
),
|
||||
field(
|
||||
"max_runs",
|
||||
"form",
|
||||
"integer",
|
||||
False,
|
||||
"10",
|
||||
"Optional cap on the number of runs.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-instance-schedule-delete",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
|
||||
title="Delete a schedule",
|
||||
summary="Remove a schedule from an instance.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"project_slug",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"PROJECT_SLUG",
|
||||
"Project slug or uid.",
|
||||
),
|
||||
field(
|
||||
"uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
|
||||
),
|
||||
field(
|
||||
"sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-create",
|
||||
method="POST",
|
||||
path="/admin/containers/create",
|
||||
title="Admin create instance",
|
||||
summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
|
||||
field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
|
||||
field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
|
||||
field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-edit",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/edit",
|
||||
title="Admin edit instance",
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
|
||||
field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
|
||||
field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
|
||||
field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
|
||||
field("mem_limit", "form", "string", False, "512m", "Memory limit."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-action",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/{action}",
|
||||
title="Admin instance lifecycle",
|
||||
summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-sync",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/sync",
|
||||
title="Admin bidirectional sync",
|
||||
summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
sample_response={"exported": 3, "imported": 1},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-delete",
|
||||
method="POST",
|
||||
path="/admin/containers/{uid}/delete",
|
||||
title="Admin delete instance",
|
||||
summary="Soft-delete an instance and mark its container for removal.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-project-search",
|
||||
method="GET",
|
||||
path="/admin/containers/projects/search",
|
||||
title="Admin project search",
|
||||
summary="Search projects by title for the admin create form (returns uid, slug, title).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "api", "Title fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
|
||||
),
|
||||
endpoint(
|
||||
id="containers-admin-user-search",
|
||||
method="GET",
|
||||
path="/admin/containers/users/search",
|
||||
title="Admin run-as user search",
|
||||
summary="Search users by username for the run-as-user select (returns uid, username).",
|
||||
auth="admin",
|
||||
params=[
|
||||
field("q", "query", "string", False, "alice", "Username fragment."),
|
||||
],
|
||||
sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -41,7 +41,7 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/profile/{username}",
|
||||
title="View a profile",
|
||||
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
|
||||
summary="Render a user profile. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
@@ -344,7 +344,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
@@ -363,7 +363,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
|
||||
@@ -4,7 +4,7 @@ from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "tools",
|
||||
"title": "Tools (SEO, DeepSearch & AI Usage Analyzer)",
|
||||
"title": "Tools (SEO & DeepSearch)",
|
||||
"intro": """
|
||||
# Tools: SEO Diagnostics & DeepSearch
|
||||
|
||||
@@ -17,10 +17,6 @@ technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readi
|
||||
synthesises a cited report with confidence scoring and gap analysis, plus a grounded chat over
|
||||
the results.
|
||||
|
||||
**AI Usage Analyzer** classifies a git repository or website as AI slop, sophisticated AI-assisted
|
||||
work or genuine human work, and publishes a persistent report with an embeddable authenticity
|
||||
badge.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
|
||||
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
|
||||
status and report.
|
||||
@@ -207,7 +203,7 @@ status and report.
|
||||
method="GET",
|
||||
path="/tools/deepsearch/{uid}/session",
|
||||
title="DeepSearch report",
|
||||
summary="Full cited research report: summary, findings, sources and metrics. Negotiates HTML or JSON.",
|
||||
summary="Full cited research report: summary, findings, gaps, sources and metrics. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid of a finished run."),
|
||||
@@ -225,6 +221,7 @@ status and report.
|
||||
"findings": [
|
||||
{"title": "Invention", "detail": "...", "confidence": 0.8, "citations": [1]}
|
||||
],
|
||||
"gaps": ["Limited coverage of later MOSFET developments."],
|
||||
"sources": [{"url": "https://example.com", "title": "Example", "source": "httpx"}],
|
||||
"chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
|
||||
"export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
|
||||
@@ -232,190 +229,5 @@ status and report.
|
||||
"export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-run",
|
||||
method="POST",
|
||||
path="/tools/isslop/run",
|
||||
title="Queue a AI usage analysis",
|
||||
summary="Start a background authenticity analysis of a git repository or website. Returns the job uid plus status, events and report URLs.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("url", "form", "string", True, "https://github.com/owner/repository", "Repository (http/git/ssh) or website URL to classify."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status_url": "/tools/isslop/ISSLOP_UID",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-list",
|
||||
method="GET",
|
||||
path="/tools/isslop/list",
|
||||
title="My AI usage analyses",
|
||||
summary="List the caller's analyses, newest first. Member history is account-bound; guest history is session-bound and claimed by the account on first signed-in call.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("limit", "query", "integer", False, "50", "Maximum analyses to return (1-200)."),
|
||||
],
|
||||
sample_response={
|
||||
"analyses": [
|
||||
{
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-status",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}",
|
||||
title="AI usage analysis status",
|
||||
summary="Poll an analysis. Once completed, grade, category and the human/AI split are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid returned when the run was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"slop_score": 31.2,
|
||||
"origin_score": 28.0,
|
||||
"quality_deficit_score": 22.5,
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"confidence": "medium",
|
||||
"files_total": 120,
|
||||
"files_analyzed": 96,
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-events",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/events",
|
||||
title="AI usage analysis event trail",
|
||||
summary="The persisted, ordered event trail of an analysis. Use ?after=SEQ to poll incrementally; live frames also stream on the pub/sub topic.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("after", "query", "integer", False, "0", "Return only events with a sequence number greater than this."),
|
||||
field("limit", "query", "integer", False, "2000", "Maximum events to return (1-5000)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "running",
|
||||
"events": [
|
||||
{"seq": 1, "kind": "stage", "message": "Resolving source type", "data": {"stage": "resolve"}, "created_at": "2026-06-14T10:00:00+00:00"}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report",
|
||||
title="AI usage analysis report",
|
||||
summary="Full report: verdict, markdown body, per-file results, image review and badge embeds. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"markdown": "# Verdict...",
|
||||
"generator_model": "molodetz",
|
||||
"badge": {
|
||||
"badge_url": "https://devplace.example/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"report_url": "https://devplace.example/tools/isslop/ISSLOP_UID/report",
|
||||
"markdown": "[](...)",
|
||||
"html": "<a href=...><img src=.../></a>",
|
||||
},
|
||||
"files": [{"path": "src/main.py", "language": "python", "lines": 120, "origin_score": 35.0, "quality_deficit_score": 18.0, "category": "human-clean", "signals": []}],
|
||||
"images": [{"path": "assets/hero.png", "ai_probability": 84.0, "grade": "F", "verdict": "ai-generated", "image_kind": "illustration", "tells": ["waxy skin"], "description": "...", "thumb_url": "/tools/isslop/ISSLOP_UID/media/0f3a9c2d1b4e5a67.webp"}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report-md",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report.md",
|
||||
title="Download report markdown",
|
||||
summary="Download the full report as a markdown file.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-source",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/source",
|
||||
title="Annotated source of a flagged file",
|
||||
summary="The persisted source of a signal-bearing file with its signals, rendered with line numbers and highlighted findings (HTML) or as JSON. Linked from the report's file table, signal chips and prose.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("path", "query", "string", True, "src/libs/Env.ts", "Workspace-relative file path from the report."),
|
||||
field("line", "query", "integer", False, "12", "Line to focus and highlight."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"path": "src/libs/Env.ts",
|
||||
"language": "typescript",
|
||||
"category": "human-clean",
|
||||
"origin_score": 24.0,
|
||||
"quality_deficit_score": 34.9,
|
||||
"source": "import { createEnv } from '@t3-oss/env-nextjs';...",
|
||||
"truncated": False,
|
||||
"signals": [{"code": "PUBLIC_ENV_SECRET", "title": "Secret exposed via public env variable", "severity": "strong", "line": 12}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-media",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/media/{name}",
|
||||
title="Reviewed image thumbnail",
|
||||
summary="Aspect-preserving WebP thumbnail of a reviewed image, persisted as evidence. The name comes from the report's images[].thumb_url.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("name", "path", "string", True, "0f3a9c2d1b4e5a67.webp", "Thumbnail file name from the report."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-badge",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/badge.svg",
|
||||
title="Authenticity badge",
|
||||
summary="Embeddable SVG badge showing the human score and authenticity grade, linking to the report.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -16,37 +16,10 @@ _RENDER_BLOCK = re.compile(
|
||||
r'<div class="docs-content" data-render>(.*?)</div>', re.DOTALL
|
||||
)
|
||||
|
||||
_HEADING = re.compile(r"<h([23])>(.*?)</h\1>", re.DOTALL)
|
||||
|
||||
|
||||
def heading_slug(text: str) -> str:
|
||||
plain = html.unescape(re.sub(r"<[^>]+>", "", text)).strip().lower()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", plain).strip("-")
|
||||
return slug or "section"
|
||||
|
||||
|
||||
def _anchor_headings(rendered: str) -> str:
|
||||
seen: dict[str, int] = {}
|
||||
|
||||
def _inject(match: re.Match) -> str:
|
||||
level, inner = match.group(1), match.group(2)
|
||||
slug = heading_slug(inner)
|
||||
count = seen.get(slug, 0)
|
||||
seen[slug] = count + 1
|
||||
if count:
|
||||
slug = f"{slug}-{count}"
|
||||
return (
|
||||
f'<h{level} id="{slug}">{inner}'
|
||||
f'<a class="docs-heading-anchor" href="#{slug}" aria-label="Link to this section">#</a>'
|
||||
f"</h{level}>"
|
||||
)
|
||||
|
||||
return _HEADING.sub(_inject, rendered)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _render_markdown(source: str) -> str:
|
||||
return _anchor_headings(_markdown(source))
|
||||
return _markdown(source)
|
||||
|
||||
|
||||
def _convert(match: re.Match) -> str:
|
||||
|
||||
+1
-27
@@ -100,11 +100,8 @@ from devplacepy.services.dbapi.service import DbApiJobService
|
||||
from devplacepy.services.pubsub import PubSubService
|
||||
from devplacepy.services.notification_relay import NotificationRelayService
|
||||
from devplacepy.services.live_view_relay import LiveViewRelayService
|
||||
from devplacepy.services.presence_relay import PresenceRelayService
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
||||
from devplacepy.services.jobs.isslop.service import IsslopService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
@@ -215,7 +212,6 @@ class UploadStaticFiles(StaticFiles):
|
||||
else "attachment"
|
||||
)
|
||||
response.headers["Content-Disposition"] = disposition
|
||||
response.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return response
|
||||
|
||||
|
||||
@@ -229,16 +225,6 @@ class CachedStaticFiles(StaticFiles):
|
||||
return response
|
||||
|
||||
|
||||
class FallbackStaticFiles(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
if Path(path).name == "service-worker.js":
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
ensure_data_dirs()
|
||||
@@ -260,9 +246,7 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(PubSubService())
|
||||
service_manager.register(NotificationRelayService())
|
||||
service_manager.register(LiveViewRelayService())
|
||||
service_manager.register(PresenceRelayService())
|
||||
service_manager.register(DeepsearchService())
|
||||
service_manager.register(IsslopService())
|
||||
service_manager.register(IssueCreateService())
|
||||
service_manager.register(PlanningReportService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
@@ -305,7 +289,7 @@ app.mount(
|
||||
CachedStaticFiles(directory=str(STATIC_DIR)),
|
||||
name="static_versioned",
|
||||
)
|
||||
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
@@ -570,16 +554,6 @@ async def maintenance_middleware(request: Request, call_next):
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def track_presence(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if not path.startswith(("/static", "/avatar")):
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
presence.touch(user["uid"])
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def response_timing(request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
@@ -462,26 +460,6 @@ class SeoRunForm(BaseModel):
|
||||
return text
|
||||
|
||||
|
||||
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
|
||||
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
|
||||
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
||||
|
||||
|
||||
class IsslopRunForm(BaseModel):
|
||||
url: str = Field(min_length=4, max_length=2048)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def url_scheme(cls, value):
|
||||
text = value.strip()
|
||||
text = ISSLOP_SINGLE_SLASH_PATTERN.sub(lambda match: f"{match.group(1)}://", text)
|
||||
if not ISSLOP_SCHEME_PATTERN.match(text) and not text.startswith("git@"):
|
||||
text = f"https://{text}"
|
||||
if not ISSLOP_URL_PATTERN.match(text):
|
||||
raise ValueError("URL must be an http(s), git or ssh source location")
|
||||
return text
|
||||
|
||||
|
||||
DEEPSEARCH_MIN_DEPTH = 1
|
||||
DEEPSEARCH_MAX_DEPTH = 4
|
||||
DEEPSEARCH_DEFAULT_DEPTH = 2
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
This file documents devplacepy/routers/ - route organization, the full URL prefix map, and small single-file feature behaviors. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
||||
|
||||
## Routing layout / prefix table
|
||||
|
||||
Routers in `devplacepy/routers/` are organised as a **directory tree that mirrors the endpoint (URL) path**, exactly like `tests/`. A domain with a single resource stays one flat file (`feed.py`, `posts.py`, `gists.py`, ...); a domain with several sub-resources is a **package directory** split **one file per sub-resource** (a distinct noun under the domain), never one file per individual endpoint. Each leaf module declares its own `router = APIRouter()` keeping the exact path strings, and the package `__init__.py` aggregates them with `router.include_router(...)`. Because a package exposes `.router` like a module, `main.py` mounts each domain at its prefix unchanged.
|
||||
|
||||
Prefixes are wired in `main.py`:
|
||||
|
||||
| Prefix | Router |
|
||||
|--------|--------|
|
||||
| `/auth` | auth/ package - one leaf per flow (`signup`, `login`, `logout`, `forgotpassword`, `resetpassword`) |
|
||||
| `/feed` | feed.py |
|
||||
| `/posts` | posts.py |
|
||||
| `/comments` | comments.py |
|
||||
| `/projects` | projects/ package - `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage (below). `main.py` mounts the whole `/projects` tree from this one package. See `routers/projects/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/projects/{slug}/files` | projects/files.py - per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files) |
|
||||
| `/profile` | profile/ package - `index.py` (page/search/followers/update/api-key; the **Posts** tab shows only the 10 most recent posts, while the sidebar `posts_count` stat always uses the real `count()` so it is never capped to the rendered 10), `customization.py`, `notifications.py`, `ai_correction.py` (`POST /{username}/ai-correction`, owner-or-admin AI content correction toggle + prompt), `ai_modifier.py` (`POST /{username}/ai-modifier`, owner-or-admin AI modifier toggle + prompt), `telegram.py` (`POST /{username}/telegram`, owner-or-admin Telegram pairing-code request/unpair), `usage.py` (`_ai_quota`, re-exported from the package) |
|
||||
| `/messages` | messages.py - real-time DM chat. `GET ""` (page; the conversation sidebar is one windowed SQL aggregate and an opened thread loads only the newest `CONVERSATION_MESSAGE_LIMIT` = 500 messages - keep these queries bounded), `GET /search`, `POST /send` (no-JS fallback), and `WS /messages/ws` (NOT lock-owner gated - accepts on every worker; closes `1008` for guests). The WS adds live bidirectional delivery, typing, read receipts, and in-process presence on top of the existing `messages` table; both the WS `send` and HTTP `POST /send` route through the shared `services/messaging/persist.py` `persist_message` choke point (identical audit/notification/mention) and then through `_finalize_and_broadcast`, which awaits any pending SYNC AI correction/modifier futures (DM content runs through both, so typing `@ai <instruction>` executes live) and broadcasts the FINAL corrected/modified content (the WS path passes `request=websocket` and awaits directly since HTTP middleware does not run for websockets; the frontend swaps the sender's optimistic bubble to the authoritative content). Same-worker delivery is instant via the per-worker `message_hub`; cross-worker delivery is filled by `services/messaging/relay.py` `message_relay` (per-worker ~1s DB poll on the `messages.id` watermark, deduped against the hub's delivered set). See `devplacepy/services/messaging/CLAUDE.md` |
|
||||
| `/notifications` | notifications.py |
|
||||
| `/votes` | votes.py |
|
||||
| `/reactions` | reactions.py - emoji reaction toggle on any target: `POST /reactions/{target_type}/{target_uid}` (optimistic `ReactionBar`) |
|
||||
| `/bookmarks` | bookmarks.py - bookmark/favorite toggle: `GET /bookmarks/saved` (the viewer's saved list) and `POST /bookmarks/{target_type}/{target_uid}` (toggle a bookmark) |
|
||||
| `/polls` | polls.py - poll voting: `POST /polls/{poll_uid}/vote` |
|
||||
| `/avatar` | avatar.py |
|
||||
| `/follow` | follow.py |
|
||||
| (none) | relations.py - per-user block/mute relations: `POST /block/{username}`, `/block/unblock/{username}`, `/mute/{username}`, `/mute/unmute/{username}` (soft-deletable `user_relations` rows) |
|
||||
| `/leaderboard` | leaderboard.py - `GET /leaderboard` XP/stars leaderboard page |
|
||||
| `/admin` | admin/ package - one leaf per sub-resource (`index`, `users`, `aiusage`, `aiquota`, `media`, `trash`, `settings`, `notifications`, `news`, `auditlog`, `backups`) plus the folded-in `services.py` and `containers.py` (mounted with `/services` and `/containers` sub-prefixes). `main.py` mounts the whole `/admin` tree from this one package. The `backups` leaf is the admin **Backups** dashboard (`BackupService`, kind `backup`): storage usage, backup archives, and interval/cron backup schedules (CRUD + rotation). **Archive download is restricted to the primary administrator** (the earliest-created Admin, resolved by `database.get_primary_admin_uid` / `utils.is_primary_admin`): `GET /admin/backups/{uid}/download` 403s every other admin, the `download_url` field is withheld from them at every endpoint (`can_download = is_primary_admin(admin)`, surfaced as `BackupDashboardOut.can_download_backups`), and `BackupMonitor.js` renders their Download control as a disabled button tooltipped `Not available`. See `devplacepy/services/backup/CLAUDE.md` |
|
||||
| `/admin/services` | admin/services.py |
|
||||
| `/issues` | issues/ package - issue tracker backed by Gitea (no local issue store): `index.py` (list `?state=`/`?page=`, detail `/{number}` with comments), `create.py` (async AI-enhanced filing `/create` enqueues a `issue_create` job, status at `/jobs/{uid}`), `comment.py` (synchronous, pushes to Gitea + notifies admins), `status.py` (admin open/closed), `attachments.py` (file attachments on open issues + comments, mirrored to Gitea native assets; add/list/delete with owner-or-admin + open-state guards) |
|
||||
| `/gists` | gists.py |
|
||||
| `/news` | news.py |
|
||||
| `/uploads` | uploads.py |
|
||||
| `/media` | media.py - profile media gallery item soft delete/restore: `POST /media/{uid}/delete` and `POST /media/{uid}/restore` (owner or admin) |
|
||||
| `/openai` | openai_gateway.py |
|
||||
| `/devii` | devii.py - WebSocket terminal (`/devii/ws`), page, `/devii/usage`, `/devii/session` |
|
||||
| `/zips` | zips.py - generic zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); enqueued from `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
|
||||
| `/forks` | forks.py - fork job status (`/forks/{uid}`); enqueued from `/projects/{slug}/fork`. When done its `project_url` points at the new forked project |
|
||||
| `/tools` | tools/ package - public developer tools surface. `index.py` (`/tools` landing) plus `seo.py` (**SEO Diagnostics**): `GET /tools/seo` page, `POST /tools/seo/run` (enqueue `seo` job, per-owner one-active-job cap), `GET /tools/seo/{uid}` (`SeoJobOut`), `GET /tools/seo/{uid}/report` (HTML+JSON `SeoReportOut`), `WS /tools/seo/{uid}/ws` (live progress, lock-owner gated, close `4013` retry), `GET /tools/seo/{uid}/screenshot/{n}`. Also `deepsearch.py` (**DeepSearch**): `GET /tools/deepsearch` page, `POST /tools/deepsearch/run` (enqueue `deepsearch` job, per-owner one-active-job cap; resolves the user `api_key` into the payload, guests use the internal key), `GET /tools/deepsearch/{uid}` (`DeepsearchJobOut`), `GET /tools/deepsearch/{uid}/session` (HTML+JSON `DeepsearchSessionOut`), `WS /tools/deepsearch/{uid}/ws` (live progress, lock-owner gated, `4013` retry), `WS /tools/deepsearch/{uid}/chat` (grounded RAG chat over the session collection), `POST /tools/deepsearch/{uid}/{pause|resume|cancel}`, `GET /tools/deepsearch/{uid}/export.{md,json,pdf}`. The shared owner helper is `routers/tools/_shared.py` `owner_for`. Also `isslop.py` (**AI Usage Analyzer**): `GET /tools/isslop` page, `POST /tools/isslop/run` (enqueue `isslop` job, per-owner one-active-job cap; owner = user uid or the shared `DEVII_GUEST_COOKIE` guest identity, minted when absent), `GET /tools/isslop/list` (owner history; a signed-in request first claims any guest-cookie analyses via `store.claim_guest_analyses` - a move, never a copy), `GET /tools/isslop/{uid}` (`IsslopAnalysisOut`), `GET /tools/isslop/{uid}/events` (persisted ordered event trail, `?after=SEQ` incremental poll; live frames also publish on pub/sub `public.isslop.{uid}` - the DB trail is the source of truth, pub/sub the fast path), `GET /tools/isslop/{uid}/report` (HTML+JSON `IsslopReportOut`; live `<dp-isslop-run>` while running, server-rendered report via `render_content` when done), `GET /tools/isslop/{uid}/report.md`, `GET /tools/isslop/{uid}/badge.svg` (embeddable SVG authenticity badge). Analyses/reports/badges are permanent public capability URLs (`IsslopService.cleanup` never deletes them; only the job row is swept). Surfaced by a collapsible **Tools** header dropdown (`base.html`, visible to all) |
|
||||
| `/projects/{slug}/containers` | projects/containers/ subpackage - admin per-project container manager (`instances.py` for creation/lifecycle/exec/logs/metrics/sync plus the exec websocket, `schedules.py` for cron/interval/once schedules, shared helpers in `_shared.py`). Every instance runs the shared `ppy` image. Discoverable from the project detail page (admin-only **Containers** button) and from the admin index |
|
||||
| `/admin/containers` | admin/containers.py - admin **Containers** manager: `/admin/containers` lists every instance across all projects with inline actions (start/stop/restart/terminal/edit/delete) plus a create modal (project search-select, run-as user search-select, boot language + source editor, restart policy, start-on-boot, env/ports/limits/ingress); `/admin/containers/{uid}` is the per-instance detail page (lifecycle, live logs/metrics, PTY terminal, schedules, ingress, sync, status history) and `/admin/containers/{uid}/edit` edits run-as user, boot language/script/command, restart policy, start-on-boot, and limits. `POST /admin/containers/create`, `/{uid}/edit`, `/{uid}/{start,stop,restart,pause,resume}`, `/{uid}/sync`, `/{uid}/delete` call `api.*` directly under `require_admin` (no docker/exec backend duplicated); `GET /admin/containers/projects/search` and `/users/search` back the create/edit search-selects. The lifecycle and detail views stay layered over the per-project `/projects/{slug}/containers/instances/{uid}/...` endpoints (the instance carries its `project_uid`) |
|
||||
| `/p/{slug}` | proxy.py - public ingress reverse proxy (HTTP + WebSocket) to a running container instance's published host port, opt-in per instance via `ingress_slug` |
|
||||
| `/xmlrpc` | xmlrpc.py - reverse-proxies XML-RPC calls to the forking XML-RPC bridge (`services/xmlrpc/`, supervised by `XmlrpcService` on loopback `config.XMLRPC_PORT`), which generates one XML-RPC method per documented REST endpoint from `docs_api.API_GROUPS` (`posts.create`, `feed.list`, ...). One struct of named params per call; auth via in-band `api_key`, `X-API-KEY`/`Bearer` header, or `http://user:pass@host/xmlrpc` Basic. Full introspection + `system.multicall`; REST errors become XML-RPC faults. Exempt from the rate limiter (enforced on the forwarded internal hop). See `devplacepy/services/xmlrpc/CLAUDE.md` |
|
||||
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
|
||||
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
|
||||
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}` |
|
||||
| (none) | push.py - web push + PWA: `GET`/`POST /push.json` (VAPID public key / subscription register), `GET /service-worker.js`, `GET /manifest.json` |
|
||||
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
|
||||
| `/pubsub` | pubsub.py - **database-free** publish/subscribe bus. `WS /pubsub/ws` (subscribe/unsubscribe/publish frames, `foo.*` wildcards), `POST /pubsub/publish` + `GET /pubsub/topics` (admin/internal). Lock-owner-gated WS (`4013` retry) so subscribers converge on one worker; topic authz in `services/pubsub/policy.py` (`user.{uid}.*` private, `public.*` shared, admin/internal anywhere, guests opt-in). In-process `services.pubsub.publish(topic, data)` for backends; frontend `app.pubsub`. See `devplacepy/services/pubsub/CLAUDE.md` |
|
||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||
|
||||
## Route aggregation rules
|
||||
|
||||
**Aggregation rules:** a leaf that owns the domain's collection-root (`""`) route must be the package's base router (FastAPI rejects an empty path included under an empty prefix), so the package `__init__` imports that leaf's `router` and includes the rest onto it (see `routers/issues`, `routers/admin`, `routers/projects`); leaves carved out of a former monolith keep their relative path strings and are included with no sub-prefix, while a router folded in from a deeper mount keeps its own paths and is included with a sub-prefix - `admin/__init__` includes `services.router` with `prefix="/services"` and `containers.router` with `prefix="/containers"`. Module-private helpers shared across a package's leaves live in its `_shared.py`. The `/projects` tree (project CRUD + `files.py` + `containers/`) and the `/admin` tree (every admin sub-resource plus the folded-in `services` and `containers`) are each mounted from a single package.
|
||||
|
||||
## HTML/JSON content negotiation
|
||||
|
||||
Every page/redirect endpoint also returns JSON when the client asks. Core in `devplacepy/responses.py`: `wants_json(request)` (true for `Accept: application/json` or `Content-Type: application/json`; browser `text/html` -> HTML, so existing behaviour is unchanged). **`X-Requested-With: fetch` is deliberately NOT a trigger** - the frontend sends it on form/engagement fetches expecting the old redirect, and the four legacy engagement endpoints (votes/reactions/bookmarks/polls) handle that header themselves. Two helpers replace the direct returns:
|
||||
|
||||
- **Page GETs:** `return respond(request, "x.html", context, model=XOut)` - HTML renders the template; JSON does `XOut.model_validate(context).model_dump()`. One context, two renderings.
|
||||
- **Action POSTs:** `return action_result(request, url, data=<resource|None>)` - HTML 302 redirects; JSON returns `{ok, redirect, data}`. (Set cookies on the returned response after calling it, as `auth.py` login/signup do.)
|
||||
|
||||
Response models live in `devplacepy/schemas.py` (Pydantic v2, `extra="ignore"`, all-Optional so they validate the existing context dicts directly). **Always project users through `UserOut`** (and `AdminUserOut`) - the raw user rows contain `email`/`api_key`/`password_hash`, and the models drop them; never serialize a raw user row. List item shapes vary (feed/gists/news/admin-news are wrapped `{post|gist|article: ...}`; projects are flat rows with `author_name`/`my_vote`) - match the context exactly. Errors negotiate centrally: `main.py` 404/500/validation handlers and the rate-limit/maintenance middleware, plus `utils.require_user`/`require_admin` (401/403 for JSON, 303 redirect for browsers). The four legacy AJAX endpoints (votes/reactions/bookmarks/polls) keep their original flat JSON shapes and are left untouched. Documented in `docs_api.py`'s Conventions group.
|
||||
|
||||
## FastAPI patterns
|
||||
|
||||
- **All routes are async.** Form data is validated via a typed Pydantic body param: `data: Annotated[SomeForm, Form()]` (models in `models.py`). Read raw `await request.form()` only when also handling an uploaded file (a separate `File()` param would embed the model under its parameter name).
|
||||
- **Return `RedirectResponse(url=..., status_code=302)`** for redirects - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`.
|
||||
- **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render.
|
||||
- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`.
|
||||
- **Register new routers in `main.py`:** `app.include_router(router_instance, prefix="/{path}")`
|
||||
- **`require_user(request)`** redirects guests (303) to login, but raises **401** when credentials *were* supplied yet invalid (so API clients get a clear error). Only post/comment/vote/etc. routes use it - the feed is public. `require_admin` and `require_user_api` (401-only) build on it. None of these changed signatures, so all auth schemes work through existing call sites.
|
||||
- **For a missing detail resource, `raise not_found("X not found")`** (`utils.py`) - it returns an `HTTPException(404)` that the global handler renders as `error.html`. Do not return a bare `HTMLResponse(..., status_code=404)`.
|
||||
- **Detail pages reuse `load_detail(table, target_type, slug, user)`** (`content.py`) for item+author+comments+attachments+`star_count`+`my_vote`; list pages reuse `enrich_items(items, key, authors, extra_maps, user=...)`. Prefer these over manual per-row loading (posts/projects/gists detail and feed/gists/profile lists already do).
|
||||
- **`get_current_user(request)` resolves ALL auth schemes** (`utils.py`), in order: `session` cookie -> `X-API-KEY` header -> `Authorization: Bearer <api_key>` -> `Authorization: Basic base64(username-or-email:password)`. It memoizes the result on `request.state._auth_user` (resolved once per request - it is called by both the maintenance middleware and the route) and caches users in `_user_cache` (by session token, `"k:"+api_key`, or a hash of the Basic header). Because every router already routes through this one function, API-key/Bearer/Basic auth work on every page/action with no per-route code. Use it for pages viewable by guests too (feed, news detail, projects).
|
||||
- **Post deletion must cascade:** delete comments and votes first, then the post. Always check ownership: `post["user_uid"] == user["uid"]`.
|
||||
- **Message deduplication needed** when `sender_uid == receiver_uid` (messaging yourself): `seen = set()` of message UIDs before appending to result list.
|
||||
|
||||
## Polymorphic comments and votes
|
||||
|
||||
The `comments` table uses `(target_type, target_uid)` so the same `_comment_section.html` component works for `post`, `project`, `gist`, and `news`. Votes follow the same shape via `/votes/{target_type}/{uid}`. `resolve_target_redirect()` in `comments.py` maps target_type back to the correct detail URL.
|
||||
|
||||
## Small feature notes: comment editing, post editing, inline comments
|
||||
|
||||
### Inline comment on feed cards
|
||||
|
||||
Every post card on the feed has an inline comment form (`.feed-comment-form`) beneath the post actions. It posts to `/comments/create` like the detail page comment form. Tests must scope Post button clicks to `#create-post-modal button.btn-primary:has-text('Post')` to avoid matching the inline comment's Post button.
|
||||
|
||||
### Comment editing
|
||||
|
||||
A comment's owner (only the owner, never an admin) sees an inline "Edit" button (`data-action='edit'`) in `_comment.html`. `CommentManager.toggleEditForm` swaps the `.comment-text` for a textarea seeded from its `data-raw` attribute (the raw markdown, since `contentRenderer.applyTo` overwrites `textContent` on first render), posts via `Http.send` to `POST /comments/edit/{comment_uid}`, then re-renders the new body in place with `contentRenderer.applyTo`. The route (`content.edit_comment_record`) is `is_owner`-only, writes `content` + `updated_at`, records the `comment.edit` audit event, and branches on `wants_json`: JSON clients get `CommentEditOut{uid, content, url, updated_at}`, the no-JS form falls back to a redirect to the comment anchor. Edits are NOT soft-delete related (the body is overwritten in place). Devii tool: `edit_comment` (owner-only, no confirm). Scope test Edit clicks to `.comment-action-btn:has-text('Edit')`.
|
||||
|
||||
### Post editing
|
||||
|
||||
Post owners see an "Edit" button on the post detail page that opens `#edit-post-modal`. The edit form allows changing title, content, and topic. The POST route is `/posts/edit/{post_uid}` with ownership check. The edit modal's textarea has `id="edit-content"` - tests must scope to `.comment-form textarea[name='content']` for comment operations.
|
||||
|
||||
## Gists
|
||||
|
||||
A dedicated `/gists` page for sharing code snippets. Uses `gists` table (auto-created by `dataset`).
|
||||
|
||||
### Database columns
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `uid` | text | UUID |
|
||||
| `user_uid` | text | FK -> users.uid |
|
||||
| `title` | text | Required, max 200 |
|
||||
| `description` | text | Optional, max 5000, markdown (rendered by ContentRenderer) |
|
||||
| `source_code` | text | Required, max 50000 |
|
||||
| `language` | text | One of 27 supported languages |
|
||||
| `slug` | text | `make_combined_slug(title, uid)` |
|
||||
| `stars` | int | Net vote count (via `/votes/gist/{uid}`) |
|
||||
| `created_at` | text | ISO datetime |
|
||||
|
||||
### Routes
|
||||
|
||||
| Method | Path | Handler | Auth |
|
||||
|--------|------|---------|------|
|
||||
| GET | `/gists` | `gists_page` | No |
|
||||
| GET | `/gists/{slug}` | `gist_detail` | No |
|
||||
| POST | `/gists/create` | `create_gist` | Yes |
|
||||
| POST | `/gists/delete/{slug}` | `delete_gist` | Yes (owner) |
|
||||
|
||||
### Polymorphic reuse
|
||||
|
||||
- **Comments**: Uses `_comment_section.html` with `target_type="gist"` - same component as posts/projects
|
||||
- **Voting**: Uses existing `/votes/gist/{uid}` route - updates `gists.stars`
|
||||
- **Content rendering**: Description rendered via `ContentRenderer.js` (`.rendered-content[data-render]`)
|
||||
- **Profile tab**: "Gists" tab between Projects and Activity on profile pages
|
||||
|
||||
### CodeMirror editor
|
||||
|
||||
- CodeMirror 5 loaded from CDN in `gists.html` via `{% block extra_js %}`
|
||||
- 22 language modes pre-loaded (Python, JS, TS, HTML, CSS, C, C++, Java, Go, Rust, SQL, Bash, YAML, Markdown, Swift, PHP, Ruby, Kotlin, Haskell, Lua, Perl, R, Dart, Scala)
|
||||
- `GistEditor.js` initializes CodeMirror on `#gist-source-editor` textarea
|
||||
- Language selector dropdown dynamically switches CodeMirror mode
|
||||
- `Ctrl+S` shortcut saves and submits the form
|
||||
- On form submit, `editor.save()` syncs CodeMirror content back to the hidden textarea
|
||||
|
||||
### Display
|
||||
|
||||
- Source code rendered in `<pre><code class="language-xxx">` block on detail page
|
||||
- Syntax highlighting handled by existing `highlight.js` loaded globally in `base.html`
|
||||
- Copy button uses `navigator.clipboard.writeText()`
|
||||
- Cards in listing show language badge, title, truncated description, author, star count
|
||||
|
||||
### Sitemap
|
||||
|
||||
- Latest 500 gists included in sitemap, `changefreq="weekly"`, `priority="0.6"`
|
||||
|
||||
## Feed and listing features
|
||||
|
||||
### Politics category
|
||||
|
||||
The `politics` topic is available as a feed filter sidebar item (icon `🏛`) and post topic. It defines the CSS variable `--topic-politics: #00bcd4` in `variables.css` and the `.badge-politics` class in `base.css`. It is validated like every topic via the canonical `TOPICS` list in `constants.py` (consumed by `models.py` `valid_topic`, `templating.py` `TOPICS` global, `routers/posts.py`, `docs_api`). Unlike the former `signals` topic, `politics` is also part of the bot fleet's rotation - it is in `services/bot/config.py` `CATEGORIES`/`FEED_TOPICS`, carries a modest per-persona weight in `PERSONA_CATEGORY_WEIGHTS`, and has a writing instruction in `services/bot/llm.py` `category_extras`.
|
||||
|
||||
### Date format
|
||||
|
||||
All dates displayed to users use European DD/MM/YYYY format. Implemented via:
|
||||
|
||||
- **`format_date(dt_str, include_time=False)`** in `utils.py` - converts ISO datetime -> `DD/MM/YYYY` or `DD/MM/YYYY HH:MM`
|
||||
- Registered as template global in `templating.py`: `{{ format_date(dt) }}`
|
||||
- **`time_ago()`** returns `DD/MM/YYYY` for items older than 30 days (instead of `"Xmo ago"`)
|
||||
- Services page has a JS `formatDate()` function for live polling updates
|
||||
|
||||
### Admin pagination
|
||||
|
||||
Both `/admin/users` and `/admin/news` use offset-based pagination via a reusable component:
|
||||
|
||||
- **`templates/_pagination.html`** - numbered page links with ellipsis, Previous/Next buttons, total count
|
||||
- Routes accept `?page=N` query param, clamped to valid range
|
||||
- `per_page = 25`, pagination metadata computed server-side and passed as `pagination` dict
|
||||
- Only renders when `total_pages > 1`
|
||||
- CSS in `admin.css` (`.pagination`, `.pagination-btn`, `.pagination-page`, `.pagination-ellipsis`)
|
||||
|
||||
### News detail and comments
|
||||
|
||||
News articles have an internal detail page at `/news/{slug}` with full comment support:
|
||||
|
||||
- **Route:** `GET /news/{news_slug}` in `routers/news.py` - resolves by slug first, then UUID
|
||||
- **Template:** `templates/news_detail.html` - shows image, source, grade, description, content, external link
|
||||
- **Comments:** Uses `_comment_section.html` with `target_type="news"` - same component as posts/projects
|
||||
- **`resolve_target_redirect()`** in `comments.py` handles `"news"` -> `/news/{slug}`
|
||||
- Listing links in `news.html` point to internal detail page; "Read on Source" still goes to external URL
|
||||
|
||||
### Home page (`GET /`)
|
||||
|
||||
The home route (`main.py` `landing()`) never redirects - it renders `templates/landing.html` for everyone, branching on `user`:
|
||||
|
||||
- **Guests** get the marketing hero (`Join DevPlace Free` CTA + features grid).
|
||||
- **Signed-in users** get a personalized hero (`.landing-hero-user`): avatar, "Welcome back, {username}", a `Go to your feed` CTA, a Posts/Stars/Level stat strip (`user_post_count` + the user dict's `stars`/`level`), and quick links. Styles live in `.landing-hero-user`/`.landing-welcome`/`.landing-stats`/`.landing-quicklinks` in `static/css/landing.css`.
|
||||
- Both states share the Latest Posts + Developer News + "Build With Us" sections. The **Build With Us** section is static HTML/CSS (`.landing-help-*` in `landing.css`): four cards linking to `/docs/index.html` (Documentation), `/swagger` + `/openapi.json` (API Reference), `/issues` (Contribute & Report), and Devii. The Devii card's `Launch Devii` button is a plain `<button data-devii-open>` that opens the globally mounted `DeviiTerminal` (`app.devii`) in place - no extra JS, route, schema, or Devii action; a secondary link points to `/devii/` for the full terminal page.
|
||||
- Context adds `user`, `is_authenticated`, `user_post_count`; `LandingOut` carries `is_authenticated`/`user_post_count` for the JSON form. `GET /` is documented in `docs_api.py` (id `home`, mapped to `LandingOut`).
|
||||
|
||||
### Author diversity (interleave, never drop)
|
||||
|
||||
Both the home page Latest Posts section and the main `/feed` *interleave* authors so one prolific account cannot fill a contiguous run, **without dropping any post** (the old per-author cap is gone). Helpers in `database.py`:
|
||||
|
||||
- `interleave_by_author(rows, uid_key="user_uid")` - pure reordering of a row list. Greedy and order-preserving: it walks the list and at each step emits the earliest row whose author differs from the last emitted one (deferring a same-author run to the next available author, recursively); when only same-author rows remain it emits them in order. The result is a permutation of the input - never a subset - so each author's own posts keep their relative (date) order and no two consecutive rows share an author unless the whole list is one author. The home route fetches the 6 newest posts and interleaves them.
|
||||
- `paginate_diverse(table, *clauses, ..., uid_key="user_uid", **filters)` - a drop-in replacement for `paginate` (same `(rows, next_cursor)` contract) that calls `paginate` then `interleave_by_author` on the page. Because the interleave is a pure permutation of the page, the cursor (oldest `created_at` in the page) is identical to `paginate`'s, so infinite scroll stays correct with no gaps/overlaps. `feed.py` `get_feed_posts` uses it for every tab.
|
||||
|
||||
When building any new "recent items" list, reuse these instead of raw `find(order_by=...)` / `paginate`.
|
||||
|
||||
### Listing search (feed, gists, projects)
|
||||
|
||||
The three public listings - `/feed`, `/gists`, `/projects` - share one free-text search box at the top of the left filter panel. Two reuse points keep it DRY and consistent:
|
||||
|
||||
- **Data layer:** `database.text_search_clause(table, search, fields=("title", "description"), author_field=None)` returns a single SQLAlchemy `or_(col.ilike("%term%") ...)` clause over the given columns (skipping any not present on the table), or `None` when `search` is blank or the table does not exist yet. Append it as a positional clause to `paginate` / `paginate_diverse` / `table.count(...)` **only when not None** (no search leaves the queries unchanged). Field mapping: projects/gists use `("title", "description")`, posts use `("title", "content")`. **Author-username matching:** posts/gists/projects store `user_uid`, not the author's username text, so a username query never matched a text column - the fix is the `author_field` argument. When set (all three listings pass `author_field="user_uid"`), `text_search_clause` resolves usernames matching the search term to uids via `database.get_uids_by_username_match(search)` (a capped `users.username LIKE` over the existing `idx_users_username` index) and OR-includes `columns[author_field].in_(uids)` in the same clause - no SQL JOIN, staying within the `dataset` query pattern. So each listing now matches its text fields **plus** the author username. `projects.py`, `gists.py`, `feed.py`, and the devRant `services/devrant/feed.py` all call it; never re-inline an `ilike` search clause and never add a JOIN - resolve username->uids and reuse `author_field`.
|
||||
- **View layer:** `templates/_sidebar_search.html` is the single search-box partial (the `Filter` heading + the GET form). Include it at the very top of `<aside class="sidebar-card">` with three locals: `_action` (the listing URL), `_placeholder`, and `_hidden` (a dict of `name -> value` rendered as hidden inputs so the active category/tab filter survives a search submit - e.g. `{"tab": current_tab, "topic": current_topic}` for the feed). It reads the `search` context var for the current value. Any future listing with a left filter panel reuses this partial plus `text_search_clause`; do not hand-roll another search form.
|
||||
|
||||
The `search` value is threaded back into each listing's context and exposed on `FeedOut.search` / `GistsOut.search` / `ProjectsOut.search`, documented as a `search` query param on the `feed-list` / `gists-list` / `projects-list` endpoints in `docs_api.py`, and offered to Devii as the `search` query param on `view_feed` / `list_gists` / `list_projects`.
|
||||
|
||||
### Recent comments on listing/feed cards (consistent across post, gist, project, news)
|
||||
|
||||
Every public listing card that shows the last few comments must render them with the **same visual hierarchy as the detail page** (depth indentation, vote rail, reply nesting). One pipeline drives all four surfaces - never inline a flat-only render:
|
||||
|
||||
- **Data layer:** `database.get_recent_comments_by_target_uids(target_type, target_uids, limit=3, user=None)` is the single batch helper. It runs one `ROW_NUMBER() OVER (PARTITION BY target_uid ORDER BY created_at DESC, id DESC)` window over `comments` filtered by `target_type=:tt` + `deleted_at IS NULL`, builds each item via `_build_comment_items` (identical dict shape to the detail page: `comment`/`author`/`time_ago`/`votes`/`my_vote`/`children`/`attachments`/`reactions`), then **nests the limited set by `parent_uid` into `children`** so replies render indented. Returns `{target_uid: [top-level items]}`. `get_recent_comments_by_post_uids` is now a thin wrapper delegating with `target_type="post"`, so `feed.py` is unchanged and there is no behavior drift.
|
||||
- **Routers:** `feed.py` (post), `gists.py` (gist), `projects/index.py` (project), `news.py` (news) each batch-fetch with the helper and attach `recent_comments` per item. Gists and news are wrapper dicts (`item["recent_comments"]`); projects are **flat dicts** (`project["recent_comments"]`).
|
||||
- **Schemas:** `FeedItemOut`, `GistItemOut`, `NewsListItemOut`, and the flat `ProjectListItemOut` all carry `recent_comments: list[CommentItemOut] = []`, so the `respond(model=...)` JSON exposes the same nested tree and never silently drops the key.
|
||||
- **View:** each card renders the block with the shared `_comment.html` `render_comment(c, 0)` macro inside a `.post-card-comments` wrapper (copied from `_post_card.html`). The macro recurses on `item.children` for indentation. **CSS requirement:** the template must load `post.css` (the `.comment`/`.comment-depth`/`.comment-replies` hierarchy) and `feed.css` (the `.post-card-comments` wrapper). `feed.html`/`gists.html`/`projects.html` already loaded `feed.css`; `news.html` loads neither by default, so it loads both.
|
||||
|
||||
### Landing page news
|
||||
|
||||
Articles can be toggled to appear on the landing page via `/admin/news/{uid}/landing`:
|
||||
|
||||
- **`show_on_landing`** field on `news` table
|
||||
- Landing route (`main.py` `GET /`) fetches up to 6 articles with `show_on_landing=1`
|
||||
- Rendered as a 3-column card grid with image, source, title, date (responsive -> 1 column on mobile)
|
||||
- Toggleable individually from the admin news table
|
||||
|
||||
### Public feed
|
||||
|
||||
The feed page (`GET /feed`) is accessible without authentication:
|
||||
|
||||
- Uses `get_current_user(request)` instead of `require_user()` - returns `None` for guests
|
||||
- Guests see posts but not the FAB, create modal, inline comment forms, or following tab
|
||||
- All POST routes (create, comment, vote) remain guarded by `require_user()`
|
||||
- Topnav shows Login/Sign Up for unauthenticated visitors; Messages, Admin, notifications for authenticated
|
||||
|
||||
## SEO implementation
|
||||
|
||||
All SEO features are implemented across the following locations:
|
||||
|
||||
### Core SEO utilities
|
||||
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator
|
||||
- `routers/seo.py` - robots.txt and sitemap.xml routes
|
||||
|
||||
### SEO template context
|
||||
- Every router passes `page_title`, `meta_description`, `meta_robots`, `canonical_url`, `og_title`, `og_description`, `og_image`, `og_type`, `breadcrumbs`, `page_schema` via `base_seo_context()`
|
||||
- Auth pages: `noindex,nofollow`
|
||||
- Messages/Notifications: `noindex,nofollow`
|
||||
- Profiles with < 2 posts: `noindex,follow`
|
||||
- All other pages: `index,follow`
|
||||
|
||||
### Template layer
|
||||
- `templates/base.html` - dynamic `<title>`, `<meta description>`, `<link canonical>`, `<meta robots>`, Open Graph, Twitter Cards, JSON-LD injection, breadcrumb nav, CDN `dns-prefetch`/`preconnect`
|
||||
- `static/css/base.css` - `.breadcrumb` (aria-label breadcrumb nav), `.sr-only` (accessible hidden headings)
|
||||
|
||||
### Heading hierarchy
|
||||
- `feed.html` - `<h1 class="sr-only">Feed</h1>`
|
||||
- `profile.html` - username rendered as `<h1 class="profile-name">`
|
||||
- `messages.html` - `<h1 class="sr-only">Messages</h1>`
|
||||
- `projects.html` - `<h1>Projects</h1>`
|
||||
- `post.html` - post title as `<h1>`, "Related Discussions" as `<h3>`
|
||||
|
||||
### Post slugs
|
||||
- Slug generated on post creation via `slugify()` and stored in `posts.slug` column
|
||||
- Posts can be looked up by slug or UUID
|
||||
- Minimum content validation: post body >= 10 chars, comment >= 3 chars
|
||||
|
||||
### Related posts
|
||||
- `templates/post.html` - "Related Discussions" widget at bottom of post page (queried by matching topic)
|
||||
|
||||
### Performance
|
||||
- `loading="lazy"` on all avatar images
|
||||
- `dns-prefetch` + `preconnect` for CDN resources in `<head>`
|
||||
- Security headers middleware: `X-Robots-Tag`, `X-Content-Type-Options`
|
||||
|
||||
### Default OG image
|
||||
- `static/og-default.svg` - 1200x630 SVG with DevPlace branding
|
||||
- Used as fallback `og:image` on all pages
|
||||
|
||||
### SEO tests
|
||||
- SEO tests are split by surface: `tests/api/robotstxt.py`, `tests/api/sitemapxml.py`, and `tests/unit/seo.py` plus the per-page e2e checks - covering robots.txt, sitemap.xml, page titles, noindex, canonical URLs, OG tags, Twitter cards, structured data, security headers
|
||||
|
||||
## Engagement: reactions, bookmarks, polls, contribution heatmap
|
||||
|
||||
### Emoji reactions
|
||||
- Curated palette only: `REACTION_EMOJI` in `constants.py` (registered as a template global). `ReactionForm` rejects anything outside it; free-text emoji are not allowed.
|
||||
- Endpoint `POST /reactions/{target_type}/{target_uid}` (`routers/reactions.py`) toggles one `(user, target, emoji)` row in the `reactions` table. Target types: `post`, `comment`, `gist`, `project`. AJAX (`x-requested-with: fetch`) returns `{counts, mine}`.
|
||||
- Reactions are **non-ranking** - they never touch `stars` or XP and intentionally send **no notifications** (votes already notify; reactions would be notification spam).
|
||||
- Batch reads via `get_reactions_by_targets(target_type, uids, user)` in `database.py` (used by feed, profile, comment loader, `load_detail`) - never per-row. The `_reaction_bar.html` partial takes `_type`, `_uid`, `_reactions` ({counts, mine}) and renders the full palette as toggle chips; `ReactionBar.js` uses document-level click delegation. All four engagement controllers (`ReactionBar`, `VoteManager`, `BookmarkManager`, `PollManager`) extend the shared `OptimisticAction` base (the `Http.sendForm -> render -> error` core); each keeps only its own event wiring and `_render`.
|
||||
|
||||
### Bookmarks
|
||||
- `POST /bookmarks/{target_type}/{target_uid}` toggles a `bookmarks` row; `GET /bookmarks/saved` renders the personal list (`saved.html`). Target types: `post`, `gist`, `project`, `news`.
|
||||
- `_bookmark_button.html` takes `_type`, `_uid`, `_bookmarked`; `BookmarkManager.js` swaps the label/`bookmarked` class from the JSON `{saved}`. Batch state via `get_user_bookmarks(user_uid, target_type, uids)`.
|
||||
|
||||
### Polls
|
||||
- A poll rides on a post (one `polls` row keyed by `post_uid`, options in `poll_options`, one-per-user votes in `poll_votes`). Created in `posts.py:create_poll` when `poll_question` plus >= 2 non-empty `poll_options` are submitted (capped at 6). Both `create_post` and `edit_post` accept the poll fields; `edit_post` only attaches a poll when the post has **none** yet (it never replaces an existing poll). The builders live in the create-post modal (`feed.html`) and the edit-post modal (`post.html`, rendered only when the post has no poll) using `data-poll-toggle` / `data-poll-add-option`.
|
||||
- `poll_options` accepts either repeated form fields (the web builders, which preserve commas inside an option label) **or** a single newline- or comma-separated string (the API/Devii path). `models.py:normalize_poll_options` splits a lone delimited element - applied as a `mode="before"` validator on `PostForm`/`PostEditForm` - so the documented "one per line or comma separated" agent format actually produces a multi-option poll instead of a single dropped option.
|
||||
- `POST /polls/{poll_uid}/vote` toggles the voter's choice and returns the full poll dict: voting on a new option switches, voting the **same** option again **retracts** (one vote per user, like reactions). Results (bars + `%`) are **always shown** to everyone; the chosen option gets `.chosen` + a checkmark. Guests see read-only bars with a "Log in to vote" hint (options `disabled`). Batch reads via `get_polls_by_post_uids(post_uids, user)` / `get_poll_for_post(post_uid, user)`.
|
||||
- Composer poll builder (`feed.html`, `PollManager.js`): poll inputs are `disabled` until "Add poll" is opened (so a never-opened or "Remove poll"-collapsed builder submits **nothing**); a capture-phase `submit` listener blocks an incomplete poll (question + >= 2 options) with an inline error instead of silently dropping it - capture phase + `stopImmediatePropagation()` is required so it pre-empts `FormManager`'s bubble-phase submit handlers.
|
||||
|
||||
### Cascade
|
||||
- `soft_delete_engagement(target_type, uids, deleted_by)` in `database.py` soft-deletes reactions, bookmarks and (for posts) poll rows. It is called from `content.py:delete_content_item` (for the item and its comments) and `comments.py:delete_comment` (which now soft-deletes the comment plus its attachments, votes, and engagement under one `stamp`). Add it to any new delete path. The unfiltered hard `delete_engagement` remains for GC only.
|
||||
|
||||
### Contribution heatmap and streaks
|
||||
- Derived, **no new table**: `get_activity_calendar(user_uid)` aggregates `date(created_at)` across `posts`/`comments`/`gists`/`projects` (cached 120s); `get_activity_heatmap` returns 53 Monday-aligned weeks of `{date, count, level}` (level 0-4); `get_streaks` returns `{current, longest}`. Rendered server-side in `profile.html` (`.heatmap-grid`, styles in `profile.css`). The `On Fire` badge is awarded in `check_milestone_badges` when the current streak >= 7.
|
||||
|
||||
### Follow graph listings
|
||||
- `profile.html` has Followers and Following tabs (`?tab=followers` / `?tab=following`, `?page=N`). `profile_page` computes `get_follow_counts(uid)` for the tab labels and, only for those two tabs, `_follow_people(uid, tab, current_user, page)`. The same two listings are exposed as JSON at `GET /profile/{username}/followers` and `GET /profile/{username}/following` (public, 25 per page) returning `{username, mode, count, page, total_pages, <mode>: [{uid, username, bio, is_following}]}`.
|
||||
- `database.py` helpers: `get_follow_counts(uid)` (`{followers, following}`), `get_follow_list(uid, mode, page)` (paginated people + `build_pagination`, ordered newest-first), and `get_following_among(follower_uid, target_uids)` (single IN-clause set used to set `is_following` per row, avoiding N+1). `mode` is `"followers"` (people who follow `uid`) or `"following"` (people `uid` follows).
|
||||
- Devii catalog tools `list_followers` / `list_following` (`requires_auth=False`) map to the JSON endpoints; documented in `docs_api.py` under the `profiles` group.
|
||||
|
||||
### Block and mute (`routers/relations.py`)
|
||||
A logged-in user can **block** or **mute** another user; both are one-directional and reversible. **Block** hides every piece of the blocked user's content from the blocker - posts, comments (any category), feed, listings, issue list, detail pages, and DMs - everywhere EXCEPT the blocked user's own profile page (kept fully visible so the blocker can review and unblock), and it also suppresses any notification that user would generate. **Mute** is the lighter option: it only suppresses the muted user's notifications while their content stays visible. The blocked/muted user is unaffected and is not told.
|
||||
|
||||
- **One table** `user_relations(uid, user_uid, target_uid, kind, created_at, deleted_at, deleted_by)` in `SOFT_DELETE_TABLES`; `user_uid` is the actor/owner, `kind` is `"block"` or `"mute"`. Indexes `idx_user_relations_owner_kind` (`user_uid, kind`) and `idx_user_relations_target_kind` (`target_uid, kind`, backs the DM-send reverse check), ensured in `init_db`.
|
||||
- **One cached accessor** `database.get_user_relations(viewer_uid) -> {"block": frozenset, "mute": frozenset}` (single query, both kinds), cache-invalidated under the `"relations"` cache-version name; anonymous viewer returns empty frozensets with **zero queries**. Derived helpers `get_blocked_uids`, `get_muted_uids`, `get_silenced_uids` (block | mute) and `invalidate_user_relations(uid)` (local pop + `bump_cache_version("relations")`). This is read on every content listing, so it must stay cache-hot - never query `user_relations` directly in a read path.
|
||||
- **Filtering choke points (DRY, do not re-implement inline):** `paginate`/`paginate_diverse` accept a `viewer_uid` kwarg that appends `user_uid NOT IN (blocked)` only when the table has `user_uid` and the block set is non-empty (wired in `feed`/`gists`/`projects` listings; safe no-op for bookmarks/news); the three comment loaders drop blocked authors via `database._drop_blocked(raw, user)` before building trees; `content.load_detail` returns `None` (404) for a blocked author; the landing query and the issues list filter by the block set; messages filter the conversation list/thread and `persist_message` rejects a DM when the recipient blocked the sender (returns `None`, already handled).
|
||||
- **Notification suppression is one line** at the single funnel `utils._deliver_notification`: `if related_uid and related_uid in get_silenced_uids(user_uid): return`. This covers follows, mentions, DMs, votes, reactions at once (block silences too, hence `get_silenced_uids`). A non-user `related_uid` is harmless (uuids never collide).
|
||||
- **Routes** mirror `follow.py`: `POST /block/{username}`, `POST /block/unblock/{username}`, `POST /mute/{username}`, `POST /mute/unmute/{username}` (born-live insert / soft-delete revive, `invalidate_user_relations`, best-effort audit `relation.block|unblock|mute|unmute`, `action_result`). Mounted with no prefix.
|
||||
- **Fan-out:** profile route passes `is_blocked`/`is_muted` (named to avoid the `is_self` Jinja-global collision rule) on the `respond` context + `ProfileOut`; `profile.html` renders Block/Unblock + Mute/Unmute POST forms next to Follow using the existing `data-confirm` (+`data-confirm-danger`) pattern - **no new JS**. Devii actions `block_user`/`unblock_user`/`mute_user`/`unmute_user`; docs in `docs_api.py`; audit prefix `relation` -> category `social`.
|
||||
|
||||
### CSS
|
||||
- Reactions, bookmarks, polls and the saved page live in `static/css/engagement.css`, loaded globally in `base.html` (the controls appear across feed, detail pages and comments). Heatmap styles are in `profile.css`. Follow-list rows (`.follow-row`, `.follow-user`, `.follow-pagination`) are in `profile.css`.
|
||||
|
||||
## Profile activity tab (clickable comments)
|
||||
|
||||
The profile **Activity** tab (`/profile/{username}?tab=activity`, public) interleaves the user's 10 most recent posts and 10 most recent comments, newest first. Each activity item is a plain dict built inline in `routers/profile/index.py` (no dedicated DB helper) and carries a `url` so the whole card is clickable, exactly like the feed's post cards:
|
||||
|
||||
- **Post items** get `"url": resolve_object_url("post", p["uid"])` -> `/posts/{slug}`.
|
||||
- **Comment items** get `"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}"`, the SEO-friendly parent detail URL plus the `#comment-{uid}` anchor. This is **byte-identical to a comment notification's `target_url`** (`content.create_comment_record`), so clicking an activity comment reproduces the notification click exactly: it lands on the parent post and `NotificationManager.js` scrolls to / highlights the `id="comment-{uid}"` element (`_comment.html`). Reuse `resolve_object_url` (`database.py`) - never rebuild this URL by hand, and do not call `resolve_object_url("comment", uid)` here (it re-fetches the row the loop already has).
|
||||
- **Frontend:** the card is wrapped in the shared full-card overlay link (`card-link-host` + `_card_link.html`), the same pattern `_post_card.html` uses; inner content links (mentions, URLs rendered by `render_content`) stay clickable via the existing `card-link-host a:not(.card-link)` z-index rule (`base.css`). No new CSS.
|
||||
- **API:** `ProfileOut.activities` is `list[Any]`, so the added `url` (and, on comments, the existing `target_type` + `uid` = parent target uid) flow into the JSON response with no schema change - this is the parent-post reference exposed on each comment item.
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.database import (
|
||||
db,
|
||||
@@ -12,11 +12,7 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
search_users_by_username,
|
||||
)
|
||||
from devplacepy.content import (
|
||||
can_manage_instance,
|
||||
can_view_instance,
|
||||
can_view_project_containers,
|
||||
)
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.models import ContainerAdminCreateForm, ContainerEditForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import (
|
||||
@@ -48,18 +44,11 @@ def _decorate(instances: list, viewer: dict | None = None) -> list:
|
||||
decorated = []
|
||||
for inst in instances:
|
||||
project = index.get(inst["project_uid"], {})
|
||||
if viewer is None:
|
||||
if not project or project.get("is_private"):
|
||||
continue
|
||||
manageable = False
|
||||
else:
|
||||
if not can_view_instance(inst, project or None, viewer):
|
||||
continue
|
||||
manageable = can_manage_instance(inst, project or None, viewer)
|
||||
if viewer is not None and project and not can_view_project(project, viewer):
|
||||
continue
|
||||
row = dict(inst)
|
||||
row["project_title"] = project.get("title", "")
|
||||
row["project_slug"] = project.get("slug") or project.get("uid") or ""
|
||||
row["can_manage"] = manageable
|
||||
decorated.append(row)
|
||||
decorated.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
return decorated
|
||||
@@ -76,28 +65,11 @@ def _project_of(inst: dict) -> dict:
|
||||
def _viewable_instance_or_404(uid: str, viewer: dict) -> dict:
|
||||
inst = _instance_or_404(uid)
|
||||
project = _project_of(inst)
|
||||
if not can_view_instance(inst, project or None, viewer):
|
||||
if project and not can_view_project(project, viewer):
|
||||
raise not_found("Instance not found")
|
||||
return inst
|
||||
|
||||
|
||||
def _manage_denied(
|
||||
request: Request, admin: dict, inst: dict, event_key: str
|
||||
) -> JSONResponse | None:
|
||||
project = _project_of(inst)
|
||||
if can_manage_instance(inst, project or None, admin):
|
||||
return None
|
||||
_audit_admin(
|
||||
request,
|
||||
admin,
|
||||
event_key,
|
||||
inst,
|
||||
f"admin {admin['username']} denied {event_key} on instance {inst.get('name')}",
|
||||
result="denied",
|
||||
)
|
||||
return json_error(403, "Only the container owner or the primary administrator can manage this instance")
|
||||
|
||||
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None, result: str = "success") -> None:
|
||||
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None) -> None:
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
@@ -108,7 +80,6 @@ def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summ
|
||||
metadata=metadata,
|
||||
summary=summary,
|
||||
links=[audit.instance(inst["uid"], inst.get("name"))],
|
||||
result=result,
|
||||
)
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@@ -162,7 +133,7 @@ async def project_search(request: Request, q: str = ""):
|
||||
results = [
|
||||
{"uid": r["uid"], "slug": r["slug"] or r["uid"], "title": r["title"]}
|
||||
for r in rows
|
||||
if can_view_project_containers(r, admin)
|
||||
if can_view_project(r, admin)
|
||||
][:10]
|
||||
return JSONResponse({"results": results})
|
||||
|
||||
@@ -179,7 +150,7 @@ async def container_create(
|
||||
):
|
||||
admin = require_admin(request)
|
||||
project = resolve_by_slug(get_table("projects"), data.project_slug)
|
||||
if not project or not can_view_project_containers(project, admin):
|
||||
if not project or not can_view_project(project, admin):
|
||||
return json_error(404, "project not found")
|
||||
try:
|
||||
inst = await api.create_instance(
|
||||
@@ -223,8 +194,6 @@ async def container_edit_page(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
project = _project_of(inst)
|
||||
if not can_manage_instance(inst, project or None, admin):
|
||||
return RedirectResponse(url=f"/admin/containers/{uid}", status_code=302)
|
||||
run_as_user = None
|
||||
if inst.get("run_as_uid"):
|
||||
run_as_user = get_users_by_uids([inst["run_as_uid"]]).get(inst["run_as_uid"])
|
||||
@@ -266,9 +235,6 @@ async def container_edit(
|
||||
):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
denied = _manage_denied(request, admin, inst, "container.instance.configure")
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
updated = api.update_instance_config(
|
||||
inst,
|
||||
@@ -316,11 +282,8 @@ _ACTIONS = {
|
||||
async def container_action(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
denied = _manage_denied(request, admin, inst, f"container.instance.{action}")
|
||||
if denied:
|
||||
return denied
|
||||
actor = ("user", admin["uid"])
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
if action == "restart":
|
||||
api.request_restart(inst, actor=actor)
|
||||
else:
|
||||
@@ -340,9 +303,6 @@ async def container_action(request: Request, uid: str):
|
||||
async def container_sync(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
denied = _manage_denied(request, admin, inst, "container.instance.sync")
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
counts = await api.sync_workspace(inst, admin)
|
||||
except ContainerError as exc:
|
||||
@@ -361,9 +321,6 @@ async def container_sync(request: Request, uid: str):
|
||||
async def container_delete(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
denied = _manage_denied(request, admin, inst, "container.instance.delete")
|
||||
if denied:
|
||||
return denied
|
||||
api.mark_for_removal(inst, actor=("user", admin["uid"]))
|
||||
_audit_admin(
|
||||
request,
|
||||
@@ -380,7 +337,6 @@ async def container_instance_page(request: Request, uid: str):
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
project = _project_of(inst)
|
||||
project_slug = project.get("slug") or project.get("uid") or ""
|
||||
can_manage = can_manage_instance(inst, project or None, admin)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@@ -410,7 +366,6 @@ async def container_instance_page(request: Request, uid: str):
|
||||
"schedules": store.list_schedules(inst["uid"]),
|
||||
"stats": api.instance_stats(inst["uid"]),
|
||||
"runtime": api.instance_runtime(inst),
|
||||
"can_manage": can_manage,
|
||||
"admin_section": "containers",
|
||||
},
|
||||
model=AdminContainerInstanceOut,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
This file documents the devRant compatibility API mounted at `/api`. Claude Code auto-loads it whenever a file in `routers/devrant/` is read or edited.
|
||||
|
||||
## Routing overview
|
||||
|
||||
- `/api` - `devrant/` package: `devRant`-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`.
|
||||
|
||||
## devRant compatibility API (`routers/devrant/`, `services/devrant/`)
|
||||
|
||||
A second REST protocol mounted at `/api` that reproduces the public devRant API shape on DevPlace data, so legacy devRant clients (the `./devranta` reference client is the spec; real captured responses are in `./devranta/api_test_results.json`) run unchanged. It is a pure **translation layer** over the existing domain - it adds no new content model.
|
||||
|
||||
**Layout.** `routers/devrant/` is the thin endpoint package: `__init__.py` builds the aggregate router with `dependencies=[Depends(ensure_enabled)]` (gated by the `devrant_api_enabled` setting, default on) and includes `auth.py` (login/register/profile/edit/avatar), `rants.py` (feed, CRUD, vote, favorite, comment, search), `comments.py` (read/edit/delete/vote), `notifs.py` (feed/clear); `_shared.py` holds the `dr_ok`/`dr_error` envelope helpers + the enable gate. `services/devrant/` is the logic: `params.merge_params` (merges query string + body, accepting BOTH form-encoded and JSON since devRant clients use both, GET sends auth as query params), `tokens` (issue/validate/revoke the `devrant_tokens` triple), `ids` (`as_int`, `to_unix`, `post_by_id`/`comment_by_id`/`user_by_id`), `serializers` (`serialize_rant`/`serialize_comment`, `encode_tags`/`decode_tags`), `feed` (`list_rants`/`search_rants`/`load_rant_detail`, offset pagination via `find(_limit, _offset)`), `profile` (`build_profile`), `notifications` (`build_notif_feed`/`clear_notifications`), `avatar` (`avatar_payload` + `render_png` via `cairosvg`).
|
||||
|
||||
**ID mapping (load-bearing).** devRant integer ids ARE the auto-increment `id` PK every `dataset` table already has: `rant_id`=`posts.id`, `comment_id`=`comments.id`, `user_id`=`users.id`, `token_id`=`devrant_tokens.id`. No translation table exists - `post_by_id` is `find_one(id=...)`. Serialization converts ISO `created_at` to unix via `ids.to_unix`.
|
||||
|
||||
**Auth.** `POST /api/users/auth-token` accepts username OR email, verifies with passlib, and inserts a `devrant_tokens` row (in `SOFT_DELETE_TABLES`; born-live; `key`=`secrets.token_hex`, `expire_time` from `session_max_age_days`). Every later call re-validates `(token_id, token_key, user_id)` with `tokens.resolve_user(params)`. Read endpoints take an OPTIONAL viewer (`resolve_user` may return None); write endpoints return `_shared.unauthorized()` (401) when it does.
|
||||
|
||||
**Writes reuse the audited native cores - never duplicate.** Implementing this drove four DRY extractions in `content.py` (`apply_vote`, `create_comment_record`, `delete_comment_record`, `set_bookmark`) and one in `utils.py` (`register_account`); the native `routers/votes.py`, `routers/comments.py`, and `auth/signup.py` were refactored onto the SAME functions. So a devRant rant/comment/vote awards XP, fires notifications, writes the audit row, and soft-deletes exactly like the UI path. Rant create calls `content.create_content_item` directly; rant delete calls `content.delete_content_item` (full cascade) and returns the devRant envelope.
|
||||
|
||||
**Field mappings.** Rant `text` = `title\n\n content` (inbound rants have no title, `topic` forced to `"rant"`). devRant `tags` round-trip verbatim through a new `posts.tags` JSON column (`init_db` ensures it; `decode_tags` falls back to `[topic]`). `profile_skills` is derived from `bio` (`skills_from_bio`, no native skills field). `favorite`/`unfavorite` map to bookmarks via `set_bookmark`. Avatars are real PNGs from the multiavatar engine at `GET /api/avatars/u/{username}.png`; `user_avatar.i` points at that path with a deterministic `b` background colour. The feed envelope includes the auxiliary devRant keys (`settings`, `set`, `wrw`, `dpp`, `num_notifs`, `unread`, `news`) with safe values so clients parse cleanly.
|
||||
|
||||
**Envelope.** `dr_ok(**f)` -> `{success:true, **f}`; `dr_error(msg, status, **extra)` -> `{success:false, error:msg, ...}`. Logical failures stay `200` except bad login -> `400` (matches the captured devRant behaviour). Reference client + captured responses live in `./devranta/`. Host routing (legacy clients hard-code devrant.com) is an infra/DNS concern, out of app scope.
|
||||
|
||||
**Cross-cutting note.** AI content correction (`devplacepy/services/correction.py`) hooks the two devRant direct-edit paths alongside the native content/comment/messaging entrypoints, so it applies identically across the web UI, REST/JSON API, Devii, and devRant.
|
||||
@@ -1,71 +0,0 @@
|
||||
This file documents the documentation site (`/docs`) - prose pages, API reference generation, search, and the interactive tester. Claude Code auto-loads it whenever a file in this directory is read or edited.
|
||||
|
||||
## Routing overview
|
||||
|
||||
- `(none)` - `docs.py` (`docs/` package), the documentation site (prose pages + API reference). See `DOCS_PAGES` below.
|
||||
|
||||
## Documentation site (`/docs`)
|
||||
|
||||
`routers/docs/ package` serves the docs. `DOCS_PAGES` (`routers/docs/pages.py`, re-exported from the `routers/docs` package; handlers in `routers/docs/views.py`) keeps curated prose pages (`kind: "prose"`, each with its own template under `templates/docs/<slug>.html`) and spreads `api_doc_pages()` from `devplacepy/docs_api.py` for every API reference page. Prose pages grouped by `section`: `General` (`index`, `getting-started`, `devii`, `dashboard`, `media-gallery`, `notification-settings`), admin-only `Devii internals` (`devii-*`), admin-only `Services` (`services-*`, documenting the `BaseService` framework and every background service including the live data at `GET /admin/services/data`), and admin-only `Production` (`production-*`, documenting the deployment). A page is admin-gated by `"admin": True`; search/export pick it up automatically by slug and respect the admin flag - no extra wiring.
|
||||
|
||||
## Audience tiers and navigation
|
||||
|
||||
`DOCS_PAGES` entries take optional `admin: True` (hidden + 404 for non-admins, but still indexed and surfaced only to admins by `docs_search`) and `section: "..."` (a nested sidebar group rendered by `docs_base.html`). The sidebar groups `section`s under four ordered **audience tiers** (`AUDIENCES` in `routers/docs/pages.py`): `Start here` (General), `Build with the API` (API, Components, Styles), `Contribute and internals` (Architecture, Services, Devii internals, Bots internals, Testing, Claude Code), and `Operate` (Administration, Production). `nav_groups(visible_pages)` builds the `[(audience, [(section, [pages])])]` tree from the flat visible-page list (so a section's pages collect under one heading regardless of `DOCS_PAGES` order or the API/Administration interleave from `api_doc_pages()`); `views.py` passes it as `nav`, and `docs_base.html` renders an audience super-header (`.sidebar-tier`) above each section subheading (`.sidebar-subheading`). `DOCS_PAGES` stays the canonical list for search/export/routing - the tiering is sidebar-only.
|
||||
|
||||
The public `getting-started` page (`SECTION_GENERAL`) is the new-contributor on-ramp (install/run, the four-faces workflow, validation); gate its deep-internals links with `{% if is_admin(user) %}` so guests get no 404s. Keep one canonical home per concept: the `auth` API group intro in `docs_api.py` defers method detail to the `authentication` prose page rather than re-listing the four methods. The member-facing `devii` prose page is functional; admins also get a `Devii internals` section of `devii-*` technical subpages.
|
||||
|
||||
## Single source of truth
|
||||
|
||||
`docs_api.py` `API_GROUPS` defines each reference page and its endpoints (method, path, auth, params, notes, sample_response). Add an endpoint there and the page, sidebar link, code examples, and interactive runner appear automatically.
|
||||
|
||||
## Downloads
|
||||
|
||||
`devplacepy/docs_export.py`: `/docs/download.md` returns the entire docs as one Markdown file; `/docs/download.html` returns a single **self-contained** HTML (vendored `marked` + `highlight.js` + theme inlined, the Markdown embedded base64 and rendered on open - works offline). Both are admin-filtered like the rest of the docs and reuse the same group/prose sources. Routes are declared before `/docs/{slug}.html` so `download.html` isn't caught by the slug pattern.
|
||||
|
||||
## Docs search (`/docs/search.html`)
|
||||
|
||||
A backend-rendered BM25 search over all docs pages, in `devplacepy/docs_search.py`. The corpus = prose page text (template stripped of Jinja/HTML) + each API group's intro/endpoints + the live services group; the inverted index (postings + idf) is built **once** lazily (`get_index()`, cached module-global) so a query is just postings lookups (~120us). `docs.py` special-cases `slug == "search"` (before the page registry) and renders `docs/_search.html` inside `docs_base.html` (`kind == 'search'`). Admin-only pages are filtered from results for non-admins, exactly like the sidebar. Snippets are HTML-escaped then wrapped in `<mark>` (XSS-safe). `_strip` removes `<script>`/`<style>` blocks and runs `_demarkdown` (drops headings, list/quote markers, table pipes, code fences, link/image syntax, and backtick/asterisk/tilde emphasis, but keeps `_` so identifiers like `owner_kind` stay searchable), so both the index and the snippets read as clean prose rather than raw markdown. The former `search` API group (user/recipient lookups) was renamed to slug **`lookups`** ("Search & Lookups") to free the `search` slug - keep that in mind if adding endpoints there.
|
||||
|
||||
## Token substitution
|
||||
|
||||
Intros/notes/examples may use `{{ base }}`, `{{ username }}`, `{{ api_key }}` - these are substituted server-side by `render_group()` (plain string replace, not Jinja), because the registry is data, not template source.
|
||||
|
||||
## Rendering pipeline (`kind` branch, prose rendering)
|
||||
|
||||
`docs_base.html` renders api pages via `_api_page.html` -> `_endpoint.html` per endpoint. Prose pages are rendered **server-side**: `docs.py` calls `docs_prose.render_prose(slug, ctx)` (`devplacepy/docs_prose.py`, mistune GFM with tables/strikethrough/url + `hard_wrap` to mirror the client `marked` config), which renders the template, converts the single `<div class="docs-content" data-render>` markdown block to HTML (after `html.unescape`, matching the client's `textContent` read), and **strips `data-render`**. `docs_base.html` outputs the result via `{{ prose_html|safe }}`. This eliminates the client markdown-to-HTML flicker and improves first paint. Anything outside that block (component live-demo blocks + their `<script type="module">`, the `devii.html` hero/CTA) is passed through verbatim. `hljs` highlighting and `CodeCopy` still run client-side over the now-server-rendered `<pre><code>` (text is already present, only colors/copy button appear after JS).
|
||||
|
||||
Every rendered `h2`/`h3` automatically gets a slugified `id` plus a hover permalink (`docs_prose._anchor_headings`, `heading_slug`), so any prose page can deep-link its sections; a page wanting a contents index places a `.docs-toc` nav (styled in `docs.css`) OUTSIDE the `data-render` block linking to those slugs (see `isslop-checks`). Authored example markup inside that block is therefore still HTML-escaped (`<dp-...>`); content outside the block passes through untouched. User-generated content elsewhere still uses the client `data-render` pipeline.
|
||||
|
||||
The public `Components` section (`component-*` prose pages) documents the custom web components with a **live, interactive example** on each page. The public `Styles` section (`styles`, `styles-colors`, `styles-layout`, `styles-responsiveness`, `styles-consistency`) is the design-system reference: the colour tokens and their meaning, the approved page layouts, the responsive breakpoint ladder, and the HARD structural rules every page must follow (taken from the feed/posts page as the canonical implementation). It uses the same live-demo convention (real demo markup OUTSIDE the `data-render` block, example markup inside it entity-escaped).
|
||||
|
||||
**`data-render` destroys inner HTML** (it renders `textContent`). Only group-intro / prose markdown lives inside a `data-render` block; every endpoint card, `[data-api-tester]` mount, and component live-demo lives OUTSIDE it. A prose page's `<div class="docs-content" data-render>` is rendered client-side via marked + DOMPurify on `textContent`, so any example markup shown as code inside it MUST be HTML-escaped (`<dp-dialog>`) or the browser parses it as a real element before render; the live demo itself goes in a separate block OUTSIDE the `data-render` div, where a `<script type="module">` (which imports its own component module, since it executes before `Application.js`) wires it up.
|
||||
|
||||
**`data-config` must be single-quoted:** `_endpoint.html` emits `data-config='{{ endpoint|tojson }}'`. `tojson` escapes `'` to `'`, so single quotes are safe; double quotes would break.
|
||||
|
||||
## Interactive API tester
|
||||
|
||||
The reusable widget is `static/js/ApiTester.js` (one instance per `[data-api-tester]`, wired by `ApiDocs.js` loaded in the docs `extra_js` block). It builds the param form, a **response-format picker**, live cURL/JS/Python tabs, a Send button that runs the real call, and a two-tab response area. It reads `window.DEVPLACE_DOCS` (`base`, `loggedIn`, `username`, `apiKey`, `isAdmin`) injected in `docs_base.html`. **Code blocks** are decorated by the shared `static/js/CodeBlock.js` (highlight via `hljs` + a line-number gutter + an always-visible Copy button); the widget calls `CodeBlock.refresh(pre)` on every tab switch (re-highlights - it clears `data-highlighted` first, the fix for stale tabs), the response panes use `CodeBlock.enhance(pre, {lineNumbers:false})`, and `CodeCopy.js` runs `CodeBlock.enhance` over prose `.docs-content pre`. Styling lives in `docs.css` (`.code-pre`/`.code-gutter`/`.code-has-copy`, `.format-picker`/`.format-option`, `.response-tabs`/`.response-pane`).
|
||||
|
||||
## Response-format negotiation
|
||||
|
||||
Every endpoint dict carries a `negotiation` field, set by `_classify()` in `docs_api.py` (not hand-written): `"negotiable"` (page GETs + action POSTs - toggle JSON/HTML via the `Accept` header), `"ajax"` (votes/reactions/bookmarks/polls - JSON via `X-Requested-With: fetch`, HTML shows the redirect), `"json"` (always JSON), `"none"` (avatar/proxy/redirect - no body). The picker **defaults to JSON** everywhere; `ApiTester.headerPairs()` adds `Accept: application/json|text/html` accordingly and only sends `X-Requested-With` for ajax endpoints in JSON mode, so the live request **and** the generated snippets stay in sync. The **Expected** tab (always visible, default) renders the endpoint's `sample_response`; the **Live response** tab fills in after Send.
|
||||
|
||||
## Runnable scope
|
||||
|
||||
Page GETs are `interactive: true` (they get a Send button, gated by `ctx.loggedIn`/`ctx.isAdmin` for user/admin endpoints). Mutations use `destructive: true` (confirm dialog). Only `avatar`, `gateway-passthrough`, `notifications-open`, `push-register`, `profile-regenerate-key`, and `profile-regenerate-avatar` stay `interactive: false` (image/proxy/redirect/side-effecting) - they still show the Expected tab. Admin endpoints (`auth: "admin"`) only run for admins.
|
||||
|
||||
## Enum params
|
||||
|
||||
Pass `options`, never hardcode allowed values in prose. A `field(... type="enum", options=[...])` auto-renders an "Allowed: a, b, c" line under the control in the live tester (`ApiTester.js` `buildParams`) and appends `Allowed: ...` to the Description column in the Markdown/HTML export (`docs_export.py` `_params_table`). Source enum lists from `devplacepy/constants.py` (`TOPICS`, `REACTION_EMOJI`) or the model `Literal`s (`PROJECT_TYPES`, `VOTE_TARGETS`) so docs stay in sync. Do NOT spell the values into the description - it would duplicate and drift.
|
||||
|
||||
## Minimal role documentation and validation
|
||||
|
||||
The `endpoint()` factory derives `min_role` from `auth` (`public` -> Public, `user` -> Member, `admin` -> Admin); it is rendered as the "Minimal role:" badge in `_endpoint.html` and as `*Minimal role:*` in the Markdown/HTML export. The `auth` value MUST reflect the real enforcement: `tests/api/auth/matrix.py` drives every documented endpoint (incl. the dynamic services group) as anonymous, member, and admin and asserts the enforcement matches the doc - public allows anonymous, `user` rejects anonymous (401/login-redirect), `admin` rejects a non-admin member (403 /feed-redirect). It forces explicit roles to survive the `is_first`-becomes-Admin rule. If you add/relax a route's auth, update its `auth` in `docs_api.py` or this test fails. (Example caught by it: `/notifications/counts` uses `get_current_user` and returns zeros to guests, so it is documented `public`, not `user`.)
|
||||
|
||||
## Admin-only pages
|
||||
|
||||
A group with `"admin": True` (currently `services`, `admin`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`.
|
||||
|
||||
## Dynamic Background Services page
|
||||
|
||||
The `services` group is a placeholder (`"dynamic": True`, empty endpoints). `docs.py` branches on `dynamic` and calls `build_services_group(service_manager.describe_all(), base)` to generate it live from the registered services and their `ConfigField` specs (one `POST /admin/services/{name}/config` card per service, `*_enabled` fields excluded). Add a service to `main.py` startup and it documents itself - keep `docs_api.py` import-pure (no `describe_all()` at import time).
|
||||
@@ -117,12 +117,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "presence",
|
||||
"title": "Online presence",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "ai-correction",
|
||||
"title": "AI content correction",
|
||||
@@ -148,18 +142,6 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "tools-isslop",
|
||||
"title": "AI Usage Analyzer",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "isslop-checks",
|
||||
"title": "AI Usage Analyzer checks",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
# Claude Code - the native subagent, command, and workflow setup under .claude/
|
||||
{
|
||||
"slug": "claude",
|
||||
|
||||
@@ -19,7 +19,6 @@ from devplacepy.database import (
|
||||
)
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.seo import list_page_seo, next_page_url
|
||||
from devplacepy.responses import respond
|
||||
@@ -91,7 +90,6 @@ async def feed_page(
|
||||
stats = get_site_stats()
|
||||
top_authors = get_top_authors(5)
|
||||
daily_topic = get_daily_topic()
|
||||
online_users = presence.online_users()
|
||||
|
||||
post_uids_list = [item["post"]["uid"] for item in posts]
|
||||
attachments_map = get_attachments_batch("post", post_uids_list)
|
||||
@@ -136,7 +134,6 @@ async def feed_page(
|
||||
"total_gists": stats["total_gists"],
|
||||
"top_authors": top_authors,
|
||||
"daily_topic": daily_topic,
|
||||
"online_users": online_users,
|
||||
"next_cursor": next_cursor,
|
||||
},
|
||||
model=FeedOut,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -94,28 +93,18 @@ async def issue_detail(request: Request, number: int):
|
||||
if not gitea_config().is_configured:
|
||||
raise not_found("Issue not found")
|
||||
client = runtime.get_client()
|
||||
issue_result, comments_result = await asyncio.gather(
|
||||
client.get_issue(number),
|
||||
client.list_comments(number),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(issue_result, GiteaError):
|
||||
if issue_result.status == 404:
|
||||
try:
|
||||
issue = await client.get_issue(number)
|
||||
except GiteaError as exc:
|
||||
if exc.status == 404:
|
||||
raise not_found("Issue not found")
|
||||
logger.warning("Could not load issue #%s: %s", number, issue_result)
|
||||
logger.warning("Could not load issue #%s: %s", number, exc)
|
||||
return tracker_unavailable(request)
|
||||
if isinstance(issue_result, BaseException):
|
||||
raise issue_result
|
||||
issue = issue_result
|
||||
if isinstance(comments_result, BaseException):
|
||||
if not isinstance(comments_result, GiteaError):
|
||||
raise comments_result
|
||||
logger.warning(
|
||||
"Could not load comments for issue #%s: %s", number, comments_result
|
||||
)
|
||||
try:
|
||||
comments = await client.list_comments(number)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not load comments for issue #%s: %s", number, exc)
|
||||
comments = []
|
||||
else:
|
||||
comments = comments_result
|
||||
|
||||
if user:
|
||||
mark_notifications_read_by_target(user["uid"], f"/issues?highlight={number}")
|
||||
|
||||
@@ -26,7 +26,6 @@ from devplacepy.utils import (
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import MessagesOut
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.dependencies import json_or_form
|
||||
@@ -42,8 +41,6 @@ router = APIRouter()
|
||||
|
||||
MAX_WS_ATTACHMENTS = 5
|
||||
|
||||
CONVERSATION_MESSAGE_LIMIT = 500
|
||||
|
||||
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
if "messages" not in db.tables:
|
||||
return
|
||||
@@ -56,65 +53,64 @@ def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
clear_messages_cache(user_uid)
|
||||
|
||||
def get_conversations(user_uid: str):
|
||||
if "messages" not in db.tables:
|
||||
return []
|
||||
latest = list(
|
||||
db.query(
|
||||
"SELECT * FROM ("
|
||||
" SELECT *,"
|
||||
" CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END AS other_uid,"
|
||||
" ROW_NUMBER() OVER ("
|
||||
" PARTITION BY CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END"
|
||||
" ORDER BY created_at DESC, id DESC"
|
||||
" ) AS rn"
|
||||
" FROM messages"
|
||||
" WHERE sender_uid = :me OR receiver_uid = :me"
|
||||
") WHERE rn = 1 ORDER BY created_at DESC",
|
||||
me=user_uid,
|
||||
)
|
||||
messages_table = get_table("messages")
|
||||
raw = list(messages_table.find(sender_uid=user_uid)) + list(
|
||||
messages_table.find(receiver_uid=user_uid)
|
||||
)
|
||||
seen = set()
|
||||
all_messages = []
|
||||
for m in raw:
|
||||
if m["uid"] not in seen:
|
||||
seen.add(m["uid"])
|
||||
all_messages.append(m)
|
||||
|
||||
blocked = get_blocked_uids(user_uid)
|
||||
conversations = []
|
||||
other_uids = []
|
||||
for msg in latest:
|
||||
other_uid = msg["other_uid"]
|
||||
conversation_map = {}
|
||||
other_uids = set()
|
||||
for msg in all_messages:
|
||||
other_uid = (
|
||||
msg["receiver_uid"] if msg["sender_uid"] == user_uid else msg["sender_uid"]
|
||||
)
|
||||
if other_uid in blocked:
|
||||
continue
|
||||
other_uids.append(other_uid)
|
||||
conversations.append(
|
||||
{
|
||||
"other_uid": other_uid,
|
||||
other_uids.add(other_uid)
|
||||
if (
|
||||
other_uid not in conversation_map
|
||||
or msg["created_at"] > conversation_map[other_uid]["last_message_at"]
|
||||
):
|
||||
conversation_map[other_uid] = {
|
||||
"other_user": None,
|
||||
"last_message": msg["content"],
|
||||
"last_message_at": msg["created_at"],
|
||||
"unread": msg["receiver_uid"] == user_uid and not msg["read"],
|
||||
}
|
||||
)
|
||||
|
||||
if other_uids:
|
||||
users_map = get_users_by_uids(other_uids)
|
||||
for conv in conversations:
|
||||
conv["other_user"] = users_map.get(conv["other_uid"])
|
||||
for conv in conversations:
|
||||
conv.pop("other_uid", None)
|
||||
users_map = get_users_by_uids(list(other_uids))
|
||||
for uid, conv in conversation_map.items():
|
||||
conv["other_user"] = users_map.get(uid)
|
||||
|
||||
conversations = sorted(
|
||||
conversation_map.values(),
|
||||
key=lambda c: c["last_message_at"],
|
||||
reverse=True,
|
||||
)
|
||||
return conversations
|
||||
|
||||
def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
if other_uid in get_blocked_uids(user_uid):
|
||||
return [], None
|
||||
if "messages" not in db.tables:
|
||||
return [], get_users_by_uids([other_uid]).get(other_uid)
|
||||
msgs = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE (sender_uid = :me AND receiver_uid = :other)"
|
||||
" OR (sender_uid = :other AND receiver_uid = :me)"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT :lim",
|
||||
me=user_uid,
|
||||
other=other_uid,
|
||||
lim=CONVERSATION_MESSAGE_LIMIT,
|
||||
)
|
||||
messages_table = get_table("messages")
|
||||
raw = list(messages_table.find(sender_uid=user_uid, receiver_uid=other_uid)) + list(
|
||||
messages_table.find(sender_uid=other_uid, receiver_uid=user_uid)
|
||||
)
|
||||
msgs.reverse()
|
||||
seen = set()
|
||||
msgs = []
|
||||
for m in raw:
|
||||
if m["uid"] not in seen:
|
||||
seen.add(m["uid"])
|
||||
msgs.append(m)
|
||||
msgs.sort(key=lambda m: m["created_at"])
|
||||
|
||||
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
|
||||
users_map = get_users_by_uids(user_ids)
|
||||
@@ -158,8 +154,8 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
|
||||
user["uid"], f"/messages?with_uid={with_uid}"
|
||||
)
|
||||
current_conversation = with_uid
|
||||
other_online = presence.is_online(other_user)
|
||||
other_last_seen = other_user.get("last_seen") if other_user else None
|
||||
other_online = message_hub.is_online(with_uid)
|
||||
other_last_seen = message_hub.last_seen(with_uid)
|
||||
audit.record(
|
||||
request,
|
||||
"message.read_on_view",
|
||||
@@ -265,6 +261,18 @@ def _resolve_ws_user(websocket: WebSocket):
|
||||
return _user_from_api_key(key)
|
||||
return None
|
||||
|
||||
async def _announce_presence(user_uid: str, online: bool) -> None:
|
||||
other_uids = {c["other_user"]["uid"] for c in get_conversations(user_uid) if c["other_user"]}
|
||||
if not other_uids:
|
||||
return
|
||||
frame = {
|
||||
"type": "presence",
|
||||
"user_uid": user_uid,
|
||||
"online": online,
|
||||
"last_seen": None if online else message_hub.last_seen(user_uid),
|
||||
}
|
||||
await message_hub.send_to_users(list(other_uids), frame)
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def messages_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@@ -274,10 +282,12 @@ async def messages_ws(websocket: WebSocket):
|
||||
return
|
||||
|
||||
user_uid = user["uid"]
|
||||
message_hub.register(user_uid, websocket)
|
||||
was_offline = message_hub.register(user_uid, websocket)
|
||||
message_relay.start()
|
||||
try:
|
||||
await websocket.send_json({"type": "ready", "user_uid": user_uid})
|
||||
if was_offline:
|
||||
await _announce_presence(user_uid, True)
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
kind = data.get("type")
|
||||
@@ -328,9 +338,25 @@ async def messages_ws(websocket: WebSocket):
|
||||
await message_hub.send_to_user(
|
||||
with_uid, {"type": "read", "by_uid": user_uid}
|
||||
)
|
||||
elif kind == "presence":
|
||||
with_uid = str(data.get("with_uid", "")).strip()
|
||||
if with_uid:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "presence",
|
||||
"user_uid": with_uid,
|
||||
"online": message_hub.is_online(with_uid),
|
||||
"last_seen": message_hub.last_seen(with_uid),
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("messages websocket loop failed for %s", user_uid)
|
||||
finally:
|
||||
message_hub.unregister(user_uid, websocket)
|
||||
went_offline = message_hub.unregister(user_uid, websocket)
|
||||
if went_offline:
|
||||
try:
|
||||
await _announce_presence(user_uid, False)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("presence offline announce failed for %s", user_uid)
|
||||
|
||||
@@ -40,7 +40,7 @@ from devplacepy.utils import (
|
||||
track_action,
|
||||
build_achievements,
|
||||
)
|
||||
from devplacepy.responses import respond, action_result, wants_json
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
from devplacepy.seo import (
|
||||
@@ -49,7 +49,6 @@ from devplacepy.seo import (
|
||||
website_schema,
|
||||
profile_page_schema,
|
||||
)
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.correction import schedule_correction
|
||||
from devplacepy.services.ai_modifier import schedule_modification
|
||||
@@ -181,20 +180,16 @@ async def profile_page(
|
||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||
badge_total = sum(group["total"] for group in achievements)
|
||||
badge_earned = sum(group["earned"] for group in achievements)
|
||||
include_collections = wants_json(request)
|
||||
projects = []
|
||||
if tab == "projects" or include_collections:
|
||||
projects = list(
|
||||
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
projects = [p for p in projects if can_view_project(p, current_user)]
|
||||
projects = list(
|
||||
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
projects = [p for p in projects if can_view_project(p, current_user)]
|
||||
gists_raw = list(
|
||||
get_table("gists").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
gists = []
|
||||
if tab == "gists" or include_collections:
|
||||
gists_raw = list(
|
||||
get_table("gists").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
for g in gists_raw:
|
||||
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
|
||||
for g in gists_raw:
|
||||
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
|
||||
posts_count = get_table("posts").count(
|
||||
user_uid=profile_user["uid"], deleted_at=None
|
||||
)
|
||||
@@ -374,7 +369,6 @@ async def profile_page(
|
||||
"is_blocked": is_blocked,
|
||||
"is_muted": is_muted,
|
||||
"is_owner": is_owner,
|
||||
"profile_online": presence.is_online(profile_user),
|
||||
"viewer_is_admin": viewer_is_admin,
|
||||
"media": media,
|
||||
"media_pagination": media_pagination,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
This file documents the project detail page, the per-project virtual filesystem, and the visibility/read-only UI-level behavior for code under `routers/projects/`. Claude Code auto-loads it whenever a file in this directory is read or edited.
|
||||
|
||||
## Routing overview
|
||||
|
||||
- `/projects` - `projects/` package: `index.py` (listing/detail/create/delete plus owner-only visibility toggles `POST /{slug}/private` and `POST /{slug}/readonly`, and async `POST /{slug}/fork`), `files.py`, and the `containers/` subpackage. `main.py` mounts the whole `/projects` tree from this one package.
|
||||
- `/projects/{slug}/files` - `projects/files.py`, the per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: `lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append` for large text files).
|
||||
|
||||
## Project Detail Page
|
||||
|
||||
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
|
||||
|
||||
**Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.
|
||||
|
||||
**Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`).
|
||||
|
||||
## Project Filesystem
|
||||
|
||||
Each project carries a virtual filesystem so it can hold a whole software project. Logic lives in `devplacepy/project_files.py` (mirrors `attachments.py`); routes in `routers/projects/files.py` (registered at prefix `/projects`, after `projects.router`). It is **public read, owner write** - reads use `get_current_user`, mutations use `require_user` + `is_owner(project, user)`. Two project flags layer on top (see **Project visibility and read-only** below): a private project's reads are restricted to owner/admin, and a read-only project refuses every mutation at the data layer.
|
||||
|
||||
- **Model.** One `project_files` table, each row a node keyed by a normalized POSIX `path` unique per project: `uid, project_uid, user_uid, path, name, parent_path, type` (`file`/`dir`), `content` (text in DB), `is_binary`, `stored_name`, `directory`, `mime_type`, `size`, timestamps. Indexes `(project_uid, path)` and `(project_uid, parent_path)` in `init_db`.
|
||||
- **Text vs binary.** Text files store `content` in the DB (editable inline). Binary uploads write bytes to `config.PROJECT_FILES_DIR/<shard>/<stored_name>` (= `data/uploads/project_files/...`, reusing `attachments._directory_for`/`_detect_mime`) and are served via the existing `/static/uploads` mount. The `<shard>` is the canonical two-level `xx/yy` tree taken from the **random tail** of the uuid7 (`{tail[-2:]}/{tail[-4:-2]}`), never its time-ordered head - see the root CLAUDE.md "Blob sharding" note for why the tail is mandatory. `store_upload` decodes UTF-8 texty files into editable DB content; everything else is binary.
|
||||
- **Path is the security control.** `normalize_path` rejects `..`, null bytes, control chars, and empty/over-long paths; a leading `/` is normalized to relative. The logical `path` is never used as a real FS path (blobs are uid-hashed), so traversal is structurally impossible. `write`/`upload`/`mkdir` create parent dirs recursively (`ensure_dirs`). Caps: `MAX_TEXT_CHARS` 400000, `MAX_FILES_PER_PROJECT` 5000.
|
||||
- **Routes (all POST mutations, JSON via `respond`/`action_result`; file path travels in query/body `path`, not a path param):** `GET .../files` (page or `ProjectFilesOut`), `GET .../files/raw?path=`, `POST .../files/{write,upload,mkdir,move,delete}`. `move` rewrites a dir's descendants by path prefix; `delete` is recursive and unlinks blobs.
|
||||
- **Line-range editing (large text files).** `GET .../files/lines?path=&start=&end=` returns `{path, start, end, total_lines, lines, content}`; `POST .../files/{replace-lines,insert-lines,delete-lines,append}` mutate a text file surgically without resending the whole thing. Helpers in `project_files.py` (`read_lines`, `replace_lines`, `insert_lines`, `delete_lines`, `append_lines`) work on a `(lines, trailing_newline)` model via `_split_lines`/`_join_lines`, are 1-indexed inclusive, enforce `MAX_TEXT_CHARS`, and reject binary/dir/missing paths. These are the preferred way to edit existing files; `write` replaces the whole file. They never create parents (the file must exist).
|
||||
- **Cascade.** `content.py` `delete_content_item` calls `delete_all_project_files(uid)` when `target_type == "project"`.
|
||||
- **Frontend.** `templates/project_files.html` (IDE layout: tree + CodeMirror editor / media preview, same CodeMirror vendor as gists), `static/js/ProjectFiles.js` (tree from the flat list, open/save/new/upload/rename/delete over JSON), `static/css/project_files.css`. The CodeMirror mode map is shared via `static/js/codemirrorModes.js` (used by `GistEditor.js` too).
|
||||
- **Devii + API.** `http` catalog actions cover the full filesystem: `project_list_files`/`read_file`/`read_lines`/`write_file`/`replace_lines`/`insert_lines`/`delete_lines`/`append_file`/`upload_file`/`make_dir`/`move_file`/`delete_file`, plus the `project-files` `docs_api.py` group. Because `PlatformClient` sends `Accept: application/json`, the same routes serve the agent and the API.
|
||||
- **Overwrite-protection guard (agent only).** `Dispatcher._run_http` blocks `project_write_file` for a `(slug, path)` only when the file **already exists** and the agent has not read it this session - it must call `project_read_file` first. Existence is probed live via `_file_exists` (`GET /projects/{slug}/files/raw`, 200 = exists, 404 = new), so creating a brand-new file is never blocked. Reads of `project_read_file`/`project_read_lines` (and a successful write) record the path in the per-session `Dispatcher._read_files` set; the key is `normalize_path`d so read and write paths match. This enforces "look before you overwrite" and steers the agent to the surgical line tools for existing files; it does not affect the human UI or the public HTTP API (the guard lives in the Devii dispatcher, not the route). The system prompt's `WRITING AND EDITING FILES` rule mirrors this.
|
||||
|
||||
## Project visibility and read-only
|
||||
|
||||
Two owner-controlled flags on the `projects` row, both integer `0`/`1` (default `0`, set on the create insert so the columns always exist): `is_private` hides the project from other viewers, `read_only` freezes its filesystem against every mutation. **The full security predicates** (`content.can_view_project`, `owns_instance`, `can_view_project_containers`, `can_view_instance`, `can_manage_instance`, the primary-administrator container isolation rules) are documented once in the root CLAUDE.md's "Project visibility (`is_private`) and read-only" section - this file covers only the project-page/filesystem UI-level behavior that sits on top of those predicates.
|
||||
|
||||
**Toggle routes** (owner-only, `routers/projects/index.py`): `POST /projects/{slug}/private` and `POST /projects/{slug}/readonly`, each taking `ProjectFlagForm{value: bool}` and routed through `_set_project_flag`. The create form carries an `is_private` checkbox (`ProjectForm.is_private`); `project_detail.html` shows Private/Read-only badges and owner toggle buttons (**both** the private and read-only buttons carry `data-confirm` so `ModalManager.initConfirmations` gates them through `app.dialog`), and `project_files.html` hides the editing toolbar + shows a banner when read-only. Schemas: `is_private`/`read_only` on `ProjectOut` and `ProjectDetailOut`, `read_only`/`is_private` on `ProjectFilesOut`.
|
||||
|
||||
**Devii.** Actions `project_set_private` and `project_set_readonly` (catalog). **Both** are gated by an explicit-confirmation requirement: `dispatcher.confirmation_error(name, arguments)` (driven by the `CONFIRM_REQUIRED` set, which lists both action names) is checked in `Dispatcher.dispatch` BEFORE any HTTP call and returns a `ToolInputError` telling the agent to obtain user confirmation unless `confirm` is truthy (the message for `project_set_private` adapts to the `value` argument: making private vs. making public). Each action declares `confirm` as a required body param, and the `PROJECT PRIVACY AND READ-ONLY` system-prompt rule tells the agent to ask first and only then pass `confirm=true`. This mirrors the overwrite-protection guard pattern above: a Devii-only gate that the human UI and HTTP API do not see.
|
||||
|
||||
### Deletion confirmation gate
|
||||
|
||||
`confirmation_error` also blocks every Devii deletion until `confirm` is truthy. Unconditional entries in `CONFIRM_REQUIRED`: every content delete - `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news` - plus `project_set_readonly` and the customization mutations; a generic fallback message covers any name in the set that lacks a bespoke message. **Load-bearing invariant: every gated tool MUST declare a `confirm` body param** (the `confirm()` helper in `catalog.py`, or `arg("confirm", ..., kind="boolean")` for container actions). Tool schemas are `additionalProperties: false`, so the model can only send arguments that are declared properties - a gated tool with NO `confirm` param can never be confirmed, so `confirmation_error` refuses every call and the agent loops forever asking for a confirmation it has no way to express. This was a real production issue: an admin asked Devii to delete a user's 27 posts, confirmed repeatedly, and all 27 `delete_post` calls were rejected because `delete_post` (and `delete_project`/`delete_media`/`project_delete_file`/`container_*`) never exposed `confirm`. The param is harmlessly forwarded to the HTTP endpoint (routes do not declare it, FastAPI drops undeclared form fields) and ignored by the LOCAL container/customization controllers. Conditional entries (gated by inspecting arguments, not membership): `container_instance_action` only when `action="delete"` (start/stop/restart/pause/resume/sync are unaffected), and `container_exec` only when its `command` matches `dispatcher.DESTRUCTIVE_COMMAND` - a token-aware regex for `rm`/`rmdir`/`unlink`/`shred`/`truncate`/`dd`/`mkfs*`/`wipefs` (with or without a leading `sudo`), `find ... -delete`, redirection over a file (`> /...`), and SQL `drop table|database` / `delete from`. Benign commands (`pip install`, `ls`, `grep -rm`) are not matched. The error message echoes the exact path/command so the agent shows the user what will be removed; after a clear yes it retries with `confirm=true`. The `DELETING IS ALWAYS CONFIRMED` system-prompt section in `agent.py` mirrors the rule. This exists because an unconfirmed `container_exec rm -f` once deleted a live project database mid-"test the site"; the gate is the data-loss backstop. The regex is a heuristic (it cannot catch `python -c "os.remove(...)"` and similar), so it is defense-in-depth, not a sandbox - the system-prompt rule is the primary control.
|
||||
|
||||
## Async fork
|
||||
|
||||
`POST /projects/{slug}/fork` enqueues an async job that copies the whole virtual filesystem into a new project owned by the forking user. See `devplacepy/services/jobs/CLAUDE.md` for `ForkService` internals.
|
||||
@@ -6,7 +6,7 @@ from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.content import can_manage_instance, can_view_project_containers
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.responses import json_error
|
||||
from devplacepy.utils import not_found
|
||||
from devplacepy.services.containers import store
|
||||
@@ -22,7 +22,6 @@ def audit_instance(
|
||||
project: dict | None = None,
|
||||
summary: str | None = None,
|
||||
metadata: Any = None,
|
||||
result: str | None = None,
|
||||
) -> None:
|
||||
links = [audit.instance(inst["uid"], inst.get("name"))]
|
||||
if project:
|
||||
@@ -37,7 +36,6 @@ def audit_instance(
|
||||
metadata=metadata,
|
||||
summary=summary or f"{user['username']} {event_key} instance {inst.get('name')}",
|
||||
links=links,
|
||||
result=result or "success",
|
||||
)
|
||||
|
||||
|
||||
@@ -45,28 +43,11 @@ def project_for(project_slug: str, user: dict | None = None) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project:
|
||||
raise not_found("Project not found")
|
||||
if user is not None and not can_view_project_containers(project, user):
|
||||
if user is not None and not can_view_project(project, user):
|
||||
raise not_found("Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def manage_guard(
|
||||
request: Request, user: dict, project: dict, inst: dict, event_key: str
|
||||
) -> JSONResponse | None:
|
||||
if can_manage_instance(inst, project, user):
|
||||
return None
|
||||
audit_instance(
|
||||
request,
|
||||
user,
|
||||
event_key,
|
||||
inst,
|
||||
project,
|
||||
summary=f"admin {user['username']} denied {event_key} on instance {inst.get('name')}",
|
||||
result="denied",
|
||||
)
|
||||
return json_error(403, "Only the container owner or the primary administrator can manage this instance")
|
||||
|
||||
|
||||
def slug_of(project: dict) -> str:
|
||||
return project["slug"] or project["uid"]
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from fastapi import Depends, APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.content import can_manage_instance, can_view_project_containers
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.models import ContainerExecForm, ContainerInstanceForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import ContainersOut
|
||||
@@ -34,7 +34,6 @@ from devplacepy.routers.projects.containers._shared import (
|
||||
audit_instance,
|
||||
fail,
|
||||
instance_for,
|
||||
manage_guard,
|
||||
project_for,
|
||||
slug_of,
|
||||
)
|
||||
@@ -149,9 +148,6 @@ async def delete_instance(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
denied = manage_guard(request, user, project, inst, "container.instance.delete")
|
||||
if denied:
|
||||
return denied
|
||||
api.mark_for_removal(inst, actor=("user", user["uid"]))
|
||||
audit_instance(
|
||||
request, user, "container.instance.delete", inst, project,
|
||||
@@ -171,9 +167,6 @@ async def instance_exec(
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
denied = manage_guard(request, user, project, inst, "container.instance.exec")
|
||||
if denied:
|
||||
return denied
|
||||
if not inst.get("container_id"):
|
||||
return json_error(400, "instance is not running")
|
||||
result = await get_backend().exec(
|
||||
@@ -224,9 +217,6 @@ async def instance_sync(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
denied = manage_guard(request, user, project, inst, "container.instance.sync")
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
counts = await api.sync_workspace(inst, user)
|
||||
except ContainerError as exc:
|
||||
@@ -249,11 +239,8 @@ async def instance_action(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
denied = manage_guard(request, user, project, inst, f"container.instance.{action}")
|
||||
if denied:
|
||||
return denied
|
||||
actor = ("user", user["uid"])
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
if action == "restart":
|
||||
api.request_restart(inst, actor=actor)
|
||||
else:
|
||||
@@ -279,7 +266,7 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
await websocket.close(code=1013)
|
||||
return
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project or not can_view_project_containers(project, user):
|
||||
if not project or not can_view_project(project, user):
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
inst = store.get_instance(uid)
|
||||
@@ -290,20 +277,6 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
):
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
if not can_manage_instance(inst, project, user):
|
||||
audit.record(
|
||||
websocket,
|
||||
"container.instance.shell.open",
|
||||
user=user,
|
||||
target_type="instance",
|
||||
target_uid=inst["uid"],
|
||||
target_label=inst.get("name"),
|
||||
summary=f"admin {user['username']} denied shell access on instance {inst.get('name')}",
|
||||
result="denied",
|
||||
links=[audit.instance(inst["uid"], inst.get("name"))],
|
||||
)
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
if inst.get("status") != "running":
|
||||
await websocket.send_text(
|
||||
"\r\n[devplace] This container is "
|
||||
|
||||
@@ -16,7 +16,6 @@ from devplacepy.utils import require_admin
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.routers.projects.containers._shared import (
|
||||
instance_for,
|
||||
manage_guard,
|
||||
project_for,
|
||||
slug_of,
|
||||
)
|
||||
@@ -34,9 +33,6 @@ async def create_schedule(
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
denied = manage_guard(request, user, project, inst, "container.schedule.create")
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
schedule = Schedule(
|
||||
kind=data.kind,
|
||||
@@ -69,9 +65,6 @@ async def delete_schedule(request: Request, project_slug: str, uid: str, sid: st
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
denied = manage_guard(request, user, project, inst, "container.schedule.delete")
|
||||
if denied:
|
||||
return denied
|
||||
store.delete_schedule(sid)
|
||||
audit.record(
|
||||
request,
|
||||
|
||||
@@ -34,7 +34,6 @@ from devplacepy.content import (
|
||||
first_image_url,
|
||||
is_owner,
|
||||
can_view_project,
|
||||
can_view_project_containers,
|
||||
)
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
@@ -231,7 +230,6 @@ async def project_detail(request: Request, project_slug: str):
|
||||
else [],
|
||||
"is_private": bool(project.get("is_private")),
|
||||
"read_only": bool(project.get("read_only")),
|
||||
"viewer_can_containers": can_view_project_containers(project, user),
|
||||
"forked_from": forked_from,
|
||||
"fork_count": count_forks(project["uid"]),
|
||||
"file_count": count_files(project["uid"]),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import deepsearch, index, isslop, seo
|
||||
from . import deepsearch, index, seo
|
||||
|
||||
router = index.router
|
||||
router.include_router(seo.router, prefix="/seo")
|
||||
router.include_router(deepsearch.router, prefix="/deepsearch")
|
||||
router.include_router(isslop.router, prefix="/isslop")
|
||||
|
||||
@@ -206,49 +206,36 @@ async def deepsearch_status(request: Request, uid: str):
|
||||
DeepsearchJobOut.model_validate(_job_payload(job)).model_dump(mode="json")
|
||||
)
|
||||
|
||||
def _report_from_disk(uid: str) -> dict:
|
||||
path = DEEPSEARCH_DIR / uid / "report.json"
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
def _report_for(job: dict) -> dict:
|
||||
if job.get("status") != queue.DONE:
|
||||
return {}
|
||||
|
||||
def _report_for(uid: str, job: dict) -> dict:
|
||||
if job.get("status") == queue.DONE:
|
||||
report = job.get("result", {}).get("report", {})
|
||||
if report:
|
||||
return report
|
||||
if job.get("status") == queue.FAILED:
|
||||
return {}
|
||||
return _report_from_disk(uid)
|
||||
return job.get("result", {}).get("report", {})
|
||||
|
||||
def _session_context(request: Request, uid: str, job: dict, session: dict) -> dict:
|
||||
report = _report_for(uid, job)
|
||||
report = _report_for(job)
|
||||
user = get_current_user(request)
|
||||
viewer_is_admin = is_admin(user)
|
||||
done = bool(report) or job.get("status") == queue.DONE
|
||||
cost_usd = report.get("cost_usd", 0.0) if report else 0.0
|
||||
ctx = {
|
||||
done = job.get("status") == queue.DONE
|
||||
return {
|
||||
"uid": uid,
|
||||
"status": queue.DONE if done else job.get("status", ""),
|
||||
"status": job.get("status", ""),
|
||||
"query": report.get("query") or session.get("query"),
|
||||
"depth": int(session.get("depth") or 0),
|
||||
"max_pages": int(session.get("max_pages") or 0),
|
||||
"score": report.get("score"),
|
||||
"confidence": report.get("confidence"),
|
||||
"source_diversity": report.get("source_diversity"),
|
||||
"synthesis": report.get("synthesis", ""),
|
||||
"page_count": report.get("page_count", 0),
|
||||
"chunk_count": report.get("chunk_count", 0),
|
||||
"summary": report.get("summary", ""),
|
||||
"sources": report.get("sources", []),
|
||||
"findings": report.get("findings", []),
|
||||
"gaps": report.get("gaps", []),
|
||||
"timeline": report.get("timeline", []),
|
||||
"chat_ws_url": f"/tools/deepsearch/{uid}/chat" if done else None,
|
||||
"export_md_url": f"/tools/deepsearch/{uid}/export.md" if done else None,
|
||||
"export_json_url": f"/tools/deepsearch/{uid}/export.json" if done else None,
|
||||
"export_pdf_url": f"/tools/deepsearch/{uid}/export.pdf" if done else None,
|
||||
"cost_usd": cost_usd if viewer_is_admin else None,
|
||||
"viewer_is_admin": viewer_is_admin,
|
||||
"viewer_owns": _owns(request, session),
|
||||
"created_at": session.get("created_at"),
|
||||
@@ -257,7 +244,6 @@ def _session_context(request: Request, uid: str, job: dict, session: dict) -> di
|
||||
"user": user,
|
||||
"meta_robots": "noindex,nofollow",
|
||||
}
|
||||
return ctx
|
||||
|
||||
@router.get("/{uid}/session")
|
||||
async def deepsearch_session(request: Request, uid: str):
|
||||
@@ -296,13 +282,10 @@ def _control(request: Request, uid: str, state: str):
|
||||
|
||||
def _export_report(uid: str) -> dict | None:
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "deepsearch" or job.get("status") == queue.FAILED:
|
||||
return None
|
||||
report = _report_for(uid, job)
|
||||
if not report:
|
||||
if not job or job.get("kind") != "deepsearch" or job.get("status") != queue.DONE:
|
||||
return None
|
||||
queue.touch_job(uid, TOUCH_EXTEND_SECONDS)
|
||||
return report
|
||||
return job.get("result", {}).get("report", {})
|
||||
|
||||
@router.get("/{uid}/export.md")
|
||||
async def deepsearch_export_md(request: Request, uid: str):
|
||||
@@ -371,10 +354,7 @@ async def deepsearch_chat_ws(websocket: WebSocket, uid: str):
|
||||
return
|
||||
job = queue.get_job(uid)
|
||||
session = database.get_deepsearch_session(uid)
|
||||
ready = job and (
|
||||
job.get("status") == queue.DONE or (session or {}).get("status") == "done"
|
||||
)
|
||||
if not job or job.get("kind") != "deepsearch" or not ready or not session:
|
||||
if not job or job.get("kind") != "deepsearch" or job.get("status") != queue.DONE or not session:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
user = get_current_user(websocket)
|
||||
|
||||
@@ -26,17 +26,6 @@ TOOLS = [
|
||||
"structured-data, performance, accessibility and AI-readiness checks."
|
||||
),
|
||||
},
|
||||
{
|
||||
"slug": "isslop",
|
||||
"name": "AI Usage Analyzer",
|
||||
"icon": "🧪",
|
||||
"url": "/tools/isslop",
|
||||
"description": (
|
||||
"Measure how a codebase or website was made: untouched AI defaults, AI steered by a "
|
||||
"knowing hand, or work no model would ever produce. Multi-signal analysis, image "
|
||||
"forensics and a shareable authenticity badge."
|
||||
),
|
||||
},
|
||||
{
|
||||
"slug": "deepsearch",
|
||||
"name": "DeepSearch",
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
from typing import Annotated
|
||||
|
||||
import uuid_utils
|
||||
from fastapi import Depends, APIRouter, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
|
||||
from devplacepy.config import ISSLOP_MEDIA_DIR
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.dependencies import json_or_form
|
||||
from devplacepy.models import IsslopRunForm
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import IsslopAnalysisOut, IsslopListOut, IsslopReportOut, IsslopSourceOut
|
||||
from devplacepy.seo import base_seo_context, site_url, web_application_schema, website_schema
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
from devplacepy.services.jobs.isslop.badge import badge_html, badge_markdown, render_badge
|
||||
from devplacepy.services.jobs.isslop.service import topic_for
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, not_found, track_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
ACTIVE_STATES = (queue.PENDING, queue.RUNNING)
|
||||
GUEST_COOKIE_MAX_AGE = 31536000
|
||||
EVENT_LIMIT_MAX = 5000
|
||||
MEDIA_NAME_PATTERN = re.compile(r"^[a-f0-9]{16}\.webp$")
|
||||
SOURCE_NAME_PATTERN = re.compile(r"^s[a-f0-9]{16}\.txt$")
|
||||
SOURCE_LINE_CONTEXT = 400000
|
||||
|
||||
|
||||
def _owner(request: Request) -> tuple[str, str] | None:
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return "user", user["uid"]
|
||||
guest = request.cookies.get(DEVII_GUEST_COOKIE)
|
||||
if guest:
|
||||
return "guest", guest
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_owner(request: Request) -> tuple[str, str, str]:
|
||||
owner = _owner(request)
|
||||
if owner:
|
||||
return owner[0], owner[1], ""
|
||||
minted = uuid_utils.uuid7().hex
|
||||
return "guest", minted, minted
|
||||
|
||||
|
||||
def _set_guest_cookie(response, minted: str) -> None:
|
||||
if minted:
|
||||
response.set_cookie(
|
||||
DEVII_GUEST_COOKIE,
|
||||
minted,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=GUEST_COOKIE_MAX_AGE,
|
||||
)
|
||||
|
||||
|
||||
def _sync_guest_history(request: Request) -> None:
|
||||
user = get_current_user(request)
|
||||
guest = request.cookies.get(DEVII_GUEST_COOKIE)
|
||||
if user and guest:
|
||||
store.claim_guest_analyses(guest, user["uid"])
|
||||
|
||||
|
||||
def _analysis_payload(row: dict) -> dict:
|
||||
uid = row.get("uid", "")
|
||||
return {
|
||||
"uid": uid,
|
||||
"status": row.get("status", ""),
|
||||
"source_url": row.get("source_url", ""),
|
||||
"source_kind": row.get("source_kind", "") or "unknown",
|
||||
"grade": row.get("grade"),
|
||||
"slop_score": row.get("slop_score"),
|
||||
"origin_score": row.get("origin_score"),
|
||||
"quality_deficit_score": row.get("quality_deficit_score"),
|
||||
"human_percent": row.get("human_percent"),
|
||||
"ai_percent": row.get("ai_percent"),
|
||||
"category": row.get("category"),
|
||||
"confidence": row.get("confidence"),
|
||||
"files_total": int(row.get("files_total") or 0),
|
||||
"files_analyzed": int(row.get("files_analyzed") or 0),
|
||||
"detected_builder": row.get("detected_builder") or None,
|
||||
"dom_slop_score": row.get("dom_slop_score"),
|
||||
"error": row.get("error_message_text") or None,
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"badge_url": f"/tools/isslop/{uid}/badge.svg",
|
||||
"events_url": f"/tools/isslop/{uid}/events",
|
||||
"topic": topic_for(uid),
|
||||
"created_at": row.get("created_at"),
|
||||
"finished_at": row.get("finished_at"),
|
||||
}
|
||||
|
||||
|
||||
SEVERITY_RANK = {"strong": 0, "medium": 1, "weak": 2}
|
||||
SIGNAL_LINES_CAP = 8
|
||||
|
||||
|
||||
def _signal_groups(signals: list) -> list:
|
||||
groups: dict[str, dict] = {}
|
||||
for signal in signals:
|
||||
if not isinstance(signal, dict):
|
||||
continue
|
||||
code = str(signal.get("code", ""))
|
||||
entry = groups.setdefault(
|
||||
code,
|
||||
{
|
||||
"code": code,
|
||||
"title": str(signal.get("title", "")),
|
||||
"severity": str(signal.get("severity", "weak")),
|
||||
"count": 0,
|
||||
"lines": [],
|
||||
},
|
||||
)
|
||||
entry["count"] += 1
|
||||
line = signal.get("line")
|
||||
if isinstance(line, int) and len(entry["lines"]) < SIGNAL_LINES_CAP:
|
||||
entry["lines"].append(line)
|
||||
return sorted(
|
||||
groups.values(),
|
||||
key=lambda group: (SEVERITY_RANK.get(group["severity"], 3), -group["count"], group["code"]),
|
||||
)
|
||||
|
||||
|
||||
def _source_url(uid: str, path: str, line: int = 0) -> str:
|
||||
url = f"/tools/isslop/{uid}/source?path={quote(path, safe='')}"
|
||||
if line > 0:
|
||||
url += f"&line={line}#L{line}"
|
||||
return url
|
||||
|
||||
|
||||
CRITERIA_URL = "/docs/isslop-checks.html"
|
||||
|
||||
|
||||
def _linkify_sources(markdown: str, uid: str, paths: set[str], signal_codes: set[str]) -> str:
|
||||
for path in sorted(paths, key=len, reverse=True):
|
||||
escaped = re.escape(path)
|
||||
markdown = re.sub(
|
||||
rf"`{escaped}:(\d+)`",
|
||||
lambda match, p=path: f"[`{p}:{match.group(1)}`]({_source_url(uid, p, int(match.group(1)))})",
|
||||
markdown,
|
||||
)
|
||||
markdown = markdown.replace(f"`{path}`", f"[`{path}`]({_source_url(uid, path)})")
|
||||
markdown = re.sub(
|
||||
rf"(?<![\w/`\(\[]){escaped}(?::(\d+))?(?![\w/`])",
|
||||
lambda match, p=path: (
|
||||
f"[{p}:{match.group(1)}]({_source_url(uid, p, int(match.group(1)))})"
|
||||
if match.group(1)
|
||||
else f"[{p}]({_source_url(uid, p)})"
|
||||
),
|
||||
markdown,
|
||||
)
|
||||
for code in sorted(signal_codes, key=len, reverse=True):
|
||||
escaped = re.escape(code)
|
||||
markdown = markdown.replace(f"`{code}`", f"[`{code}`]({CRITERIA_URL})")
|
||||
markdown = re.sub(
|
||||
rf"(?<![\w`\[]){escaped}(?![\w`])",
|
||||
f"[{code}]({CRITERIA_URL})",
|
||||
markdown,
|
||||
)
|
||||
return markdown
|
||||
|
||||
|
||||
def _badge_info(request: Request, uid: str) -> dict:
|
||||
base = site_url(request).rstrip("/")
|
||||
badge_url = f"{base}/tools/isslop/{uid}/badge.svg"
|
||||
report_url = f"{base}/tools/isslop/{uid}/report"
|
||||
return {
|
||||
"badge_url": badge_url,
|
||||
"report_url": report_url,
|
||||
"markdown": badge_markdown(badge_url, report_url),
|
||||
"html": badge_html(badge_url, report_url),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def isslop_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
_sync_guest_history(request)
|
||||
base = site_url(request)
|
||||
description = (
|
||||
"Measure how a codebase or website was made: untouched AI defaults, AI steered by a "
|
||||
"knowing hand, or work no model would ever produce. Transparent, reproducible analysis "
|
||||
"with a shareable authenticity badge."
|
||||
)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="AI Usage Analyzer",
|
||||
description=description,
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
],
|
||||
schemas=[
|
||||
website_schema(base),
|
||||
web_application_schema("AI Usage Analyzer", description, "/tools/isslop", base),
|
||||
],
|
||||
)
|
||||
minted = "" if _owner(request) else uuid_utils.uuid7().hex
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"tools/isslop.html",
|
||||
{**seo_ctx, "request": request, "user": user},
|
||||
)
|
||||
_set_guest_cookie(response, minted)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def isslop_run(request: Request, data: Annotated[IsslopRunForm, Depends(json_or_form(IsslopRunForm))]):
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind, owner_id, minted = _ensure_owner(request)
|
||||
active = [
|
||||
job
|
||||
for job in queue.list_jobs(kind="isslop", owner=(owner_kind, owner_id))
|
||||
if job.get("status") in ACTIVE_STATES
|
||||
]
|
||||
if active:
|
||||
audit.record(
|
||||
request,
|
||||
"isslop.run.request",
|
||||
result="denied",
|
||||
summary=f"AI usage analysis denied for {data.url}: analysis already running",
|
||||
metadata={"target": data.url, "reason": "active_job", "uid": active[0]["uid"]},
|
||||
links=[audit.job(active[0]["uid"])],
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": {
|
||||
"status": 429,
|
||||
"message": "You already have an analysis running. Wait for it to finish.",
|
||||
"uid": active[0]["uid"],
|
||||
}
|
||||
},
|
||||
status_code=429,
|
||||
)
|
||||
uid = queue.enqueue(
|
||||
"isslop",
|
||||
{"url": data.url},
|
||||
owner_kind,
|
||||
owner_id,
|
||||
f"AI usage: {data.url}"[:64],
|
||||
)
|
||||
store.create_analysis(uid, data.url, owner_kind, owner_id)
|
||||
audit.record(
|
||||
request,
|
||||
"isslop.run.request",
|
||||
summary=f"requested AI usage analysis of {data.url}",
|
||||
metadata={"target": data.url},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
if owner_kind == "user":
|
||||
track_action(owner_id, "isslop")
|
||||
response = JSONResponse(
|
||||
{
|
||||
"uid": uid,
|
||||
"status_url": f"/tools/isslop/{uid}",
|
||||
"events_url": f"/tools/isslop/{uid}/events",
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"topic": topic_for(uid),
|
||||
}
|
||||
)
|
||||
_set_guest_cookie(response, minted)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def isslop_list(request: Request, limit: int = store.LIST_LIMIT_DEFAULT):
|
||||
_sync_guest_history(request)
|
||||
owner = _owner(request)
|
||||
rows = []
|
||||
if owner:
|
||||
rows = store.list_analyses(owner[0], owner[1], min(max(1, limit), 200))
|
||||
return JSONResponse(
|
||||
IsslopListOut.model_validate(
|
||||
{"analyses": [_analysis_payload(row) for row in rows]}
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}")
|
||||
async def isslop_status(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
return JSONResponse(
|
||||
IsslopAnalysisOut.model_validate(_analysis_payload(row)).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/events")
|
||||
async def isslop_events(request: Request, uid: str, after: int = 0, limit: int = store.EVENT_LIMIT_DEFAULT):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
rows = store.events_for(uid, max(0, after), min(max(1, limit), EVENT_LIMIT_MAX))
|
||||
events = [
|
||||
{
|
||||
"seq": event["seq"],
|
||||
"kind": event["kind"],
|
||||
"message": event["message"],
|
||||
"data": store.decode_json(event.get("payload"), {}),
|
||||
"created_at": event["created_at"],
|
||||
}
|
||||
for event in rows
|
||||
]
|
||||
return JSONResponse({"uid": uid, "status": row.get("status", ""), "events": events})
|
||||
|
||||
|
||||
@router.get("/{uid}/report")
|
||||
async def isslop_report(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report = store.get_report(uid)
|
||||
files = []
|
||||
linkable_paths: set[str] = set()
|
||||
signal_codes: set[str] = set()
|
||||
for item in store.file_results_for(uid):
|
||||
signals = store.decode_json(item.get("signals"), [])
|
||||
for signal in signals:
|
||||
if isinstance(signal, dict) and signal.get("code"):
|
||||
signal_codes.add(str(signal["code"]))
|
||||
source_name = str(item.get("source") or "")
|
||||
has_source = bool(source_name and SOURCE_NAME_PATTERN.match(source_name))
|
||||
path = item.get("path", "")
|
||||
if has_source:
|
||||
linkable_paths.add(path)
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"language": item.get("language", "unknown"),
|
||||
"lines": int(item.get("lines") or 0),
|
||||
"origin_score": float(item.get("origin_score") or 0.0),
|
||||
"quality_deficit_score": float(item.get("quality_deficit_score") or 0.0),
|
||||
"category": item.get("category", "uncertain"),
|
||||
"signals": signals,
|
||||
"signal_groups": _signal_groups(signals),
|
||||
"source_url": _source_url(uid, path) if has_source else None,
|
||||
}
|
||||
)
|
||||
images = [
|
||||
{
|
||||
"path": item.get("path", ""),
|
||||
"ai_probability": float(item.get("ai_probability") or 0.0),
|
||||
"grade": item.get("grade", "n/a"),
|
||||
"verdict": item.get("verdict", "uncertain"),
|
||||
"image_kind": item.get("image_kind", "image"),
|
||||
"tells": store.decode_json(item.get("tells"), []),
|
||||
"description": item.get("description", ""),
|
||||
"thumb_url": (
|
||||
f"/tools/isslop/{uid}/media/{item['thumb']}"
|
||||
if item.get("thumb") and MEDIA_NAME_PATTERN.match(str(item["thumb"]))
|
||||
else None
|
||||
),
|
||||
}
|
||||
for item in store.image_results_for(uid)
|
||||
]
|
||||
dom_pages = [
|
||||
{
|
||||
"url": item.get("url", ""),
|
||||
"detected_builder": item.get("detected_builder") or None,
|
||||
"signal_count": int(item.get("signal_count") or 0),
|
||||
"signals": store.decode_json(item.get("signals"), []),
|
||||
"screenshot_url": (
|
||||
f"/tools/isslop/{uid}/media/{item['screenshot']}"
|
||||
if item.get("screenshot") and MEDIA_NAME_PATTERN.match(str(item["screenshot"]))
|
||||
else None
|
||||
),
|
||||
}
|
||||
for item in store.dom_results_for(uid)
|
||||
]
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="AI usage analysis report",
|
||||
description=f"Authenticity analysis of {row.get('source_url', '')}",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
{"name": "Report", "url": f"/tools/isslop/{uid}/report"},
|
||||
],
|
||||
)
|
||||
context = {
|
||||
**seo_ctx,
|
||||
**_analysis_payload(row),
|
||||
"content_hash": row.get("content_hash"),
|
||||
"markdown": _linkify_sources(report.get("markdown", ""), uid, linkable_paths, signal_codes) if report else "",
|
||||
"generator_model": report.get("model_used", "") if report else "",
|
||||
"generated_at": report.get("generated_at") if report else None,
|
||||
"badge": _badge_info(request, uid),
|
||||
"files": files,
|
||||
"images": images,
|
||||
"dom_pages": dom_pages,
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
}
|
||||
return respond(request, "tools/isslop_report.html", context, model=IsslopReportOut)
|
||||
|
||||
|
||||
@router.get("/{uid}/report.md")
|
||||
async def isslop_report_markdown(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report = store.get_report(uid)
|
||||
if not report:
|
||||
raise not_found("Report not yet generated for this analysis")
|
||||
return Response(
|
||||
content=report["markdown"],
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"content-disposition": f'attachment; filename="ai-usage-report-{uid}.md"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/source")
|
||||
async def isslop_source(request: Request, uid: str, path: str, line: int = 0):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
result = store.file_result_for(uid, path)
|
||||
source_name = str(result.get("source") or "") if result else ""
|
||||
if not result or not SOURCE_NAME_PATTERN.match(source_name):
|
||||
raise not_found("Source not available for this file")
|
||||
source_path = (store.media_dir_for(uid) / source_name).resolve()
|
||||
if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file():
|
||||
raise not_found("Source not available for this file")
|
||||
text = source_path.read_text(encoding="utf-8", errors="replace")
|
||||
signals = store.decode_json(result.get("signals"), [])
|
||||
marked: dict[int, list] = {}
|
||||
for signal in signals:
|
||||
if isinstance(signal, dict) and isinstance(signal.get("line"), int) and signal["line"] > 0:
|
||||
marked.setdefault(signal["line"], []).append(signal)
|
||||
source_lines = text.split("\n")
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=f"Source: {path}",
|
||||
description=f"Annotated source of {path} from the AI usage analysis",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Tools", "url": "/tools"},
|
||||
{"name": "AI Usage Analyzer", "url": "/tools/isslop"},
|
||||
{"name": "Report", "url": f"/tools/isslop/{uid}/report"},
|
||||
{"name": "Source", "url": _source_url(uid, path)},
|
||||
],
|
||||
)
|
||||
context = {
|
||||
**seo_ctx,
|
||||
"uid": uid,
|
||||
"path": path,
|
||||
"language": result.get("language", "unknown"),
|
||||
"category": result.get("category", "uncertain"),
|
||||
"origin_score": float(result.get("origin_score") or 0.0),
|
||||
"quality_deficit_score": float(result.get("quality_deficit_score") or 0.0),
|
||||
"source": text,
|
||||
"truncated": len(text) >= SOURCE_LINE_CONTEXT,
|
||||
"signals": signals,
|
||||
"source_lines": source_lines,
|
||||
"marked_lines": marked,
|
||||
"focus_line": max(0, line),
|
||||
"report_url": f"/tools/isslop/{uid}/report",
|
||||
"request": request,
|
||||
"user": get_current_user(request),
|
||||
}
|
||||
return respond(request, "tools/isslop_source.html", context, model=IsslopSourceOut)
|
||||
|
||||
|
||||
@router.get("/{uid}/media/{name}")
|
||||
async def isslop_media(request: Request, uid: str, name: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row or not MEDIA_NAME_PATTERN.match(name):
|
||||
raise not_found("Image not available")
|
||||
root = ISSLOP_MEDIA_DIR.resolve()
|
||||
path = (store.media_dir_for(uid) / name).resolve()
|
||||
if not path.is_relative_to(root) or not path.is_file():
|
||||
raise not_found("Image not available")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="image/webp",
|
||||
headers={"cache-control": "public, max-age=86400"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{uid}/badge.svg")
|
||||
async def isslop_badge(request: Request, uid: str):
|
||||
row = store.get_analysis(uid)
|
||||
if not row:
|
||||
raise not_found("Analysis not found")
|
||||
report_url = _badge_info(request, uid)["report_url"]
|
||||
svg = render_badge(row.get("human_percent"), row.get("grade"), report_url)
|
||||
return Response(
|
||||
content=svg,
|
||||
media_type="image/svg+xml",
|
||||
headers={"cache-control": "no-cache, max-age=300"},
|
||||
)
|
||||
@@ -86,10 +86,6 @@ from devplacepy.schemas.jobs import (
|
||||
SeoMetaOut,
|
||||
SeoReportOut,
|
||||
ZipJobOut,
|
||||
IsslopAnalysisOut,
|
||||
IsslopListOut,
|
||||
IsslopReportOut,
|
||||
IsslopSourceOut,
|
||||
)
|
||||
from devplacepy.schemas.backups import (
|
||||
BackupDashboardOut,
|
||||
|
||||
@@ -73,7 +73,6 @@ class AdminContainerInstanceOut(_Out):
|
||||
schedules: list = []
|
||||
stats: Optional[Any] = None
|
||||
runtime: Optional[Any] = None
|
||||
can_manage: bool = False
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ class UserOut(_Out):
|
||||
xp: Optional[int] = None
|
||||
stars: Optional[int] = None
|
||||
created_at: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
|
||||
|
||||
class AttachmentOut(_Out):
|
||||
|
||||
@@ -125,18 +125,17 @@ class DeepsearchSessionOut(_Out):
|
||||
score: Optional[int] = None
|
||||
confidence: Optional[float] = None
|
||||
source_diversity: Optional[float] = None
|
||||
synthesis: str = ""
|
||||
page_count: int = 0
|
||||
chunk_count: int = 0
|
||||
summary: Optional[str] = None
|
||||
sources: list = []
|
||||
findings: list = []
|
||||
gaps: list = []
|
||||
timeline: list = []
|
||||
chat_ws_url: Optional[str] = None
|
||||
export_md_url: Optional[str] = None
|
||||
export_json_url: Optional[str] = None
|
||||
export_pdf_url: Optional[str] = None
|
||||
cost_usd: Optional[float] = None
|
||||
viewer_is_admin: bool = False
|
||||
viewer_owns: bool = False
|
||||
created_at: Optional[str] = None
|
||||
@@ -154,75 +153,3 @@ class DbQueryJobOut(_Out):
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopAnalysisOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
source_url: str = ""
|
||||
source_kind: str = ""
|
||||
grade: Optional[str] = None
|
||||
slop_score: Optional[float] = None
|
||||
origin_score: Optional[float] = None
|
||||
quality_deficit_score: Optional[float] = None
|
||||
human_percent: Optional[float] = None
|
||||
ai_percent: Optional[float] = None
|
||||
category: Optional[str] = None
|
||||
confidence: Optional[str] = None
|
||||
files_total: int = 0
|
||||
files_analyzed: int = 0
|
||||
detected_builder: Optional[str] = None
|
||||
dom_slop_score: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
report_url: Optional[str] = None
|
||||
badge_url: Optional[str] = None
|
||||
events_url: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopListOut(_Out):
|
||||
analyses: list = []
|
||||
|
||||
|
||||
class IsslopReportOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
source_url: str = ""
|
||||
source_kind: str = ""
|
||||
grade: Optional[str] = None
|
||||
slop_score: Optional[float] = None
|
||||
origin_score: Optional[float] = None
|
||||
quality_deficit_score: Optional[float] = None
|
||||
human_percent: Optional[float] = None
|
||||
ai_percent: Optional[float] = None
|
||||
category: Optional[str] = None
|
||||
confidence: Optional[str] = None
|
||||
files_total: int = 0
|
||||
files_analyzed: int = 0
|
||||
detected_builder: Optional[str] = None
|
||||
dom_slop_score: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
content_hash: Optional[str] = None
|
||||
markdown: str = ""
|
||||
generator_model: str = ""
|
||||
generated_at: Optional[str] = None
|
||||
badge: dict = {}
|
||||
files: list = []
|
||||
images: list = []
|
||||
dom_pages: list = []
|
||||
created_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopSourceOut(_Out):
|
||||
uid: str = ""
|
||||
path: str = ""
|
||||
language: str = ""
|
||||
category: str = ""
|
||||
origin_score: float = 0.0
|
||||
quality_deficit_score: float = 0.0
|
||||
source: str = ""
|
||||
truncated: bool = False
|
||||
signals: list = []
|
||||
|
||||
@@ -113,7 +113,6 @@ class FeedOut(_Out):
|
||||
total_gists: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
daily_topic: Optional[Any] = None
|
||||
online_users: list[UserOut] = []
|
||||
|
||||
|
||||
class PostDetailOut(_Out):
|
||||
@@ -158,7 +157,6 @@ class ProjectDetailOut(_Out):
|
||||
platforms: Optional[Any] = None
|
||||
is_private: bool = False
|
||||
read_only: bool = False
|
||||
viewer_can_containers: bool = False
|
||||
forked_from: Optional[dict] = None
|
||||
fork_count: int = 0
|
||||
file_count: int = 0
|
||||
|
||||
@@ -40,7 +40,6 @@ class ProfileOut(_Out):
|
||||
is_blocked: bool = False
|
||||
is_muted: bool = False
|
||||
is_owner: bool = False
|
||||
profile_online: bool = False
|
||||
can_view_api_key: bool = False
|
||||
api_key: Optional[str] = None
|
||||
ai_correction_enabled: bool = False
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
This file documents the service files that live directly in devplacepy/services/ (background task queue, AI correction/modifier, presence, live view relay, and the BaseService/ServiceManager base machinery). Claude Code loads it automatically whenever a file directly under devplacepy/services/ (or any of its subdirectories) is read or edited - domain-specific services (Devii, containers, gateway, jobs, audit, telegram, email, gitea, news, bot, messaging, xmlrpc, dbapi, pubsub, game) have their own more specific nested CLAUDE.md files.
|
||||
|
||||
## Background task queue (`services/background.py`)
|
||||
|
||||
Generic, lightweight, **fire-and-forget** offload for non-critical side-effects so they leave the request path. The singleton `background` (`from devplacepy.services.background import background`) wraps one in-process `asyncio.Queue` drained by a single consumer task. The whole API is one call: `background.submit(fn, *args, **kwargs)` enqueues a **synchronous** callable and returns immediately (`put_nowait`). The consumer runs each callable inside its own `try/except`, so a failing task is logged and never kills the loop; FIFO order is preserved.
|
||||
|
||||
- **Per-worker, not lock-gated (the reason it is NOT a `JobService`/`BaseService`).** Producers run in every uvicorn worker, and process memory is not shared, so the drain must run wherever requests are handled. `main.py` `startup()` calls `await background.start()` for every worker (inside the `if not DEVPLACE_DISABLE_SERVICES` guard, **outside** the `acquire_service_lock()` branch); `shutdown()` calls `await background.stop()`. A `BaseService` supervisor only runs in the lock owner, which would strand records produced by the other worker. The `JobService` queue is also wrong here: it is DB-backed, so deferring a tiny audit insert would *add* writes instead of removing them.
|
||||
- **Inline fallback (load-bearing).** When the consumer is not running - tests with `DEVPLACE_DISABLE_SERVICES=1`, unit tests, request-less bootstrap, or a full queue - `submit` runs `fn` **inline and synchronously**. This keeps audit/notification/XP writes deterministic and immediately visible to the test suite (which asserts audit rows right after an action) while production defers them. No test changes are needed.
|
||||
- **Best-effort durability (intentional).** In-memory only; `stop()` drains whatever remains synchronously so a **graceful** shutdown loses nothing, but a hard crash drops unflushed items. This matches audit/notifications already being best-effort (the recorder never raises). Do not put response-critical or money-touching work on it.
|
||||
- **Capture plain data, never the `Request`.** Its lifecycle ends with the response - build the row/payload synchronously on the request thread and submit only the resulting dict/scalars.
|
||||
- **Consumers today** (deferred at their canonical choke points, so callers need no change):
|
||||
- **Audit** - `services/audit/record.py` `_write` builds the row + links synchronously, generates the `uid`/`created_at` eagerly so `record()` still returns the real uid, then `background.submit(_persist, row, links)` does the two `store` inserts off-thread.
|
||||
- **XP/rewards** - `utils.award_rewards` defers its body via `background.submit(_apply_rewards, ...)`, so every XP award (posts/comments/projects/gists/follow/votes) leaves the request path.
|
||||
- **Notifications** - `utils.create_notification` (the single notification funnel) defers via `background.submit(_deliver_notification, ...)`, so every in-app insert + push schedule + audit for a notification (vote/follow/comment/mention/message/badge/level) runs on the consumer.
|
||||
- **Mention fan-out** - `utils.create_mention_notifications` defers its whole body (`_deliver_mention_notifications`): the `extract_mentions` regex + the `@`-username lookup query + the per-user loop all run off-thread (it is called on every post/gist/project/comment/message create).
|
||||
- **Issue-comment admin fan-out** - `routers/issues/comment.py` defers the `_notify_admins` loop (admin lookup + per-admin notify) via `background.submit`, after the synchronous Gitea call.
|
||||
- Because the reward and notification funnels self-defer, request handlers just **call `award_rewards`/`create_notification` directly** - do NOT wrap them in `background.submit` (that double-queues). The pattern for any NEW side-effect: do the user-visible write inline, then call the self-deferring funnel (or `background.submit(...)` a one-off).
|
||||
- **Feature/first-use badges** ride the same self-deferring model: call `utils.track_action(user_uid, action[, target])` inline at a feature's success point (do NOT wrap it - it `background.submit`s itself), where `action` is a key in `utils.ACHIEVEMENTS` (`action -> [(threshold, badge), ...]`, threshold 1 = first use; `UNIQUE_ACTIONS` use `record_unique_activity` for "N distinct things" like `docs.read`). Source-derivable milestones (counts of existing tables) instead go in `check_milestone_badges`/`_COUNT_MILESTONES`. Badge metadata + `group` live in `BADGE_CATALOG`; the profile shows a grouped earned/locked **Achievements** showcase via `build_achievements`.
|
||||
- **Deliberately NOT deferred (would break correctness):** cache invalidations (`clear_user_cache`/`clear_unread_cache`/`clear_messages_cache`/`bump_cache_version`) must run before the response so the next read is fresh (and they are microsecond version bumps); the vote/reaction count aggregation feeds the AJAX response body; and synchronous **external** calls whose result the response needs or must surface on failure (the Gitea comment/status calls; file/thumbnail writes whose returned URL must already exist) belong in an async **JobService** (durable + retryable), not this fire-and-forget queue.
|
||||
|
||||
## AI content correction (`services/correction.py`)
|
||||
|
||||
**Opt-in, default off, server-side.** When a user turns it on (profile **AI content correction** block, owner-only, saved at `POST /profile/{username}/ai-correction`), the prose they author is rewritten by the AI gateway. The per-user `ai_correction_sync` flag (0 = background default, 1 = sync) picks the **apply mode**: background rewrites the stored fields a moment after the write (never slows the request); sync makes the HTTP response wait until the correction is applied.
|
||||
|
||||
- **Sync must never block the event loop (the self-deadlock trap).** The correction POSTs to the in-process gateway (`INTERNAL_GATEWAY_URL` = `localhost:{PORT}`), and the hooked content helpers are synchronous on the loop thread, so a blocking inline call self-deadlocks the (single) worker against its own gateway request - on a single worker that loop is the only thing that can serve the gateway request it is waiting on, so a blocking inline call deadlocks the worker until the httpx timeout, then fail-softs (server hangs, no correction). The fix: sync runs `_run_correction` via `loop.run_in_executor` (loop stays free), stashes the future on `request.scope[PENDING_SCOPE_KEY]`, and the `await_pending_corrections` middleware in `main.py` awaits it after the handler. Never reintroduce a blocking inline correction on the loop thread.
|
||||
- **Field registry is the single source of truth.** `CORRECTABLE_FIELDS: dict[str, tuple[str, ...]]` maps each correctable table to its prose columns: `posts` -> `(title, content)`, `projects`/`gists` -> `(title, description)`, `comments`/`messages` -> `(content,)`, `users` -> `(bio,)`. `gists.source_code`, project files, and Gitea issues are intentionally excluded - code and external systems are never corrected.
|
||||
- **Choke entrypoint.** `schedule_correction(user, table, uid, request=None)` is the only thing handlers call (every hook passes `request`). It is a no-op unless: a user dict is present, `table` is in the registry, `user["ai_correction_enabled"]` is truthy, and the user has a non-empty `api_key`. In **sync** mode (`user["ai_correction_sync"]`) it calls `_run_inline_awaited`, which - only when a `request.scope` and a running event loop exist - submits `_run_correction` to `loop.run_in_executor` (off the loop thread) and stashes the future on `request.scope[PENDING_SCOPE_KEY]` for the middleware to await; if there is no request/loop it returns False and falls back to background. In **background** mode (default) it `background.submit`s `_run_correction` (per-worker queue; also runs inline under `DEVPLACE_DISABLE_SERVICES=1`). The same `_run_correction` worker function runs in all cases.
|
||||
- **The hooks (covers UI + REST + Devii + devRant in one place):** `content.create_content_item` (posts/projects/gists), `content.edit_content_item`, `content.create_comment_record`, `content.edit_comment_record`, `services/messaging/persist.persist_message` (DMs - sender is the user), `routers/profile/index.update_profile` (bio), and the two devRant direct-update edit paths (`routers/devrant/rants.edit_rant`, `routers/devrant/comments.edit_comment`). devRant create paths route through the shared content cores, so they are already hooked. Code-only and external paths (project files, Gitea) are deliberately not hooked.
|
||||
- **The gateway call is fail-soft.** `correct_text(api_key, prompt, text)` is synchronous (runs on the background worker thread), POSTs to `INTERNAL_GATEWAY_URL` with `model=INTERNAL_MODEL` via `stealth.stealth_sync_client`, authenticated with the user's own `Bearer` api_key (per-user attribution). It returns the ORIGINAL text on any error, empty output, or suspiciously large output (`len > len(text) * MAX_GROWTH_FACTOR + 200`, rejecting hallucinated expansion). `_run_correction` reads the row back, corrects each registry field that has non-blank text, writes only changed fields via `table.update(updates, ["uid"])` without touching `updated_at` (auto-correction is not a user edit) or the slug (slugs are permanent), and `clear_user_cache(user_uid)` when `table == "users"` so the corrected bio re-caches.
|
||||
- **Per-user usage aggregation (only on success).** `correct_text` returns `(text, usage)`; `usage` is parsed from the gateway's `X-Gateway-*` response headers (`_usage_from_headers`) whenever the upstream call returned 200 (cost was incurred, even if the corrected output was rejected), else `None` on any failure. `_usage_from_headers` captures the token and cost headers PLUS the timing headers `X-Gateway-Upstream-Latency-Ms` and `X-Gateway-Total-Latency-Ms` (as `upstream_latency_ms`/`total_latency_ms`), so each call's timing is metered. `_run_correction` accumulates the per-field `usage` into one `totals` dict and, when `totals["calls"] > 0`, makes ONE call to `database.add_correction_usage(user_uid, totals)` (a single `totals` dict, not positional args) - so a 2-field content item is a single aggregated write, and a failed/empty correction records nothing. `add_correction_usage` (and `add_modifier_usage`) delegate to the shared `database._add_usage(usage_table, user_uid, totals)`: a single atomic `INSERT ... ON CONFLICT(user_uid) DO UPDATE SET col = col + excluded.col` upsert against the `correction_usage` table (per-user running SUMS: `calls`/`prompt_tokens`/`completion_tokens`/`total_tokens`/`cost_usd`/`upstream_latency_ms`/`total_latency_ms`/`updated_at`, unique index `idx_correction_usage_user`, the two latency columns REAL default 0.0, all ensured in `init_db`). It is a derived counter table (NOT in `SOFT_DELETE_TABLES`, like `gateway_usage_ledger`) and is deliberately separate from `users` so accumulating never invalidates the auth/user cache. `database.get_correction_usage(user_uid)` (via `_get_usage`) returns the stored sums PLUS computed averages: `avg_tokens` (total_tokens/calls), `avg_upstream_latency_ms`, `avg_total_latency_ms`, `avg_tokens_per_second` (completion_tokens over total upstream seconds), and `avg_cost_usd`.
|
||||
- **Profile display:** `routers/profile/usage._correction_usage(uid, include_cost)` shapes it via the shared `_usage_view(data, include_cost)` (mirrors `_ai_quota`); `profile/index.py` builds it only for `is_owner or viewer_is_admin` and passes `include_cost=viewer_is_admin`, exposed as the `correction_usage` dict on the context and `ProfileOut`. The view surfaces the sums plus averages - `avg_tokens` (avg tokens/call), `avg_latency_ms` (avg upstream latency), `avg_total_latency_ms`, `avg_tokens_per_second` (avg speed), and `total_time_s` (total upstream seconds) - rendered as extra tiles on the card. **Financial gating:** tokens/call-count and the performance tiles show to the owner and admins; the dollar `cost_usd` and `avg_cost_usd` keys are present ONLY when `viewer_is_admin`, in BOTH the HTML card (`templates/profile.html`, `.correction-usage-*`) and the `respond(..., model=ProfileOut)` JSON (same rule as `_ai_quota`'s `spent_usd` - hiding it in the template alone would leak it to a member fetching their own profile as JSON). The card renders only when `correction_usage.calls` > 0.
|
||||
- **Import-cycle discipline.** `correction.py` imports only `stealth`, `config`, `database.get_table`/`add_correction_usage`, and `services.background.background` at module top; `clear_user_cache` is imported lazily inside `_run_correction`. Never import `content` or `utils` at module top.
|
||||
- **Settings live on `users`:** three columns `ai_correction_enabled` (0/1), `ai_correction_sync` (0/1, default 0 = background), and `ai_correction_prompt` (text, default `config.DEFAULT_CORRECTION_PROMPT`), ensured in `database.backfill_api_keys()` (the user column-ensure block run by `init_db`) and seeded born-live in `utils._create_account`. The edit route is the owner-or-admin leaf `POST /profile/{username}/ai-correction` (`routers/profile/ai_correction.py`, `AiCorrectionForm{enabled, sync, prompt}`, audit key `profile.ai_correction`). The owner-only values are exposed on the profile page context and `ProfileOut` (`ai_correction_enabled`/`ai_correction_sync`/`ai_correction_prompt`, gated by `is_owner`), the UI block lives in `profile.html` (owner-only: enable checkbox, **Apply mode** select, prompt textarea) wired by `static/js/AiCorrection.js` (`app.aiCorrection`), and Devii drives it via the owner-scoped `ai_correction_get`/`ai_correction_set` tools (`services/devii/ai_correction/`, `handler="ai_correction"`, `requires_auth=True`, not confirm-gated - it is a reversible per-user toggle; `ai_correction_set` accepts `enabled`, optional `sync`, optional `prompt`).
|
||||
|
||||
## AI modifier (`services/ai_modifier.py`, `services/ai_context.py`)
|
||||
|
||||
**A sibling of AI content correction that runs only on an explicit inline directive.** The engine reuses the correction plumbing wholesale (`CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics, the per-user usage upsert) and differs only in the trigger and the apply-mode/enabled defaults: it is **enabled by default** and **synchronous by default**, and it runs ONLY where an authored prose field contains an inline `@ai <instruction>` directive.
|
||||
|
||||
- **The `@ai` gate is the whole difference.** `has_ai_directive(text)` matches the regex `@ai\s+\S` (case-insensitive). `schedule_modification(user, table, uid, request=None)` is a no-op unless a user is present, `table` is in `CORRECTABLE_FIELDS`, `user["ai_modifier_enabled"]` is truthy, the user has an `api_key`, AND at least one of the table's registry fields actually contains an `@ai` directive. `_run_modification` re-checks the gate per field, so untriggered fields are never sent to the gateway and never metered. Triggerless writes cost nothing. The configured prompt (default `config.DEFAULT_MODIFIER_PROMPT` = "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`") tells the model to execute the instruction and replace the marked part including the `@ai` marker.
|
||||
- **Total reuse of the correction layer.** `CORRECTABLE_FIELDS`, `PENDING_SCOPE_KEY`, `gateway_complete`, the sync/background apply-mode mechanics (`_run_inline_awaited` -> `loop.run_in_executor` + `request.scope[PENDING_SCOPE_KEY]` awaited by the `await_pending_corrections` middleware in `main.py`, the same self-deadlock-avoiding path), and the per-user usage upsert pattern are all imported from / mirror `services/correction.py`. `modify_text(api_key, prompt, text, context="")` composes a modifier system message and calls the shared `gateway_complete`. The same hooks fire it: `profile/index.update_profile` calls both `schedule_correction` and `schedule_modification`, and the content/comment/messaging cores invoke it alongside correction, so it covers the web UI, REST, devRant, and Devii in one place. Code and source files are never modified (same `CORRECTABLE_FIELDS` registry, `gists.source_code`/project files/Gitea excluded). `schedule_modification` is hooked alongside `schedule_correction` at the same content/comment/messaging/profile entrypoints.
|
||||
- **Context-aware (modifier only, not correction).** Unlike correction, the modifier gives the model a grounding **context block** so an `@ai` instruction can reason about who is asking and what it is attached to. `services/ai_context.py` `build_context(table, uid, row, user_uid) -> str` assembles it; `_run_modification` builds it **lazily once per row** (only after a field is confirmed to contain `@ai`, so triggerless writes do no extra queries) and passes it to every field's `modify_text`, which appends it to the system message under a `# Context (use it to inform the result; never echo this block)` header. The block (fail-soft, length-capped, each part wrapped in try/except so a failed lookup never blocks the modification) has three parts:
|
||||
1. **date** - `Today is DD/MM/YYYY on the DevPlace developer network.`
|
||||
2. **author/stats** - the author's username, role, level, stars, post count (`get_user_post_count`), leaderboard rank (`get_user_rank`), follower count (`get_follow_counts`), member-since date, and bio (capped `MAX_BIO`).
|
||||
3. **location/thread** - table-specific: a comment gets the target post/project/gist/news title + excerpt and the parent comment it replies to; a post gets its topic and attached project; a project/gist gets its sibling title/description and, for gists, the language + `source_code` (truncated, marked "reference only" - this is read context, the modifier still never WRITES `source_code`); a direct message gets the recipient and the last `MAX_THREAD_MESSAGES` messages of the conversation (each truncated).
|
||||
All excerpts are length-capped (`MAX_*` constants) to bound token cost; the added context tokens are billed and metered like any other input via the usage stats. So `@ai answer the question above` in a comment, `@ai write my bio from my stats`, or `@ai reply to this` in a DM all work. The message-thread query binds a param named `:current` (NOT `:self`, which collides with `db.query`'s bound-method argument).
|
||||
- **Defaults differ:** enabled **ON** by default and apply mode defaults to **synchronous** (`ai_modifier_sync` column defaults to 1), the inverse of correction.
|
||||
- **Per-user usage (only on success).** Same plumbing as correction: `_usage_from_headers` captures the token, cost, and timing headers (`X-Gateway-Upstream-Latency-Ms`/`X-Gateway-Total-Latency-Ms`); `_run_modification` accumulates per-field gateway usage into a `totals` dict and, when `totals["calls"] > 0`, makes ONE `database.add_modifier_usage(user_uid, totals)` call (a single `totals` dict) that delegates to the shared `database._add_usage` atomic `INSERT ... ON CONFLICT DO UPDATE SET col = col + excluded.col` upsert into the `modifier_usage` table (running SUMS `calls`/token/`cost_usd`/`upstream_latency_ms`/`total_latency_ms` totals, the two latency columns REAL default 0.0, separate from `users` so it never busts the auth cache, NOT in `SOFT_DELETE_TABLES`, ensured in `init_db`). `database.get_modifier_usage(user_uid)` (via `_get_usage`) returns the sums plus the same computed averages as correction (`avg_tokens`, `avg_upstream_latency_ms`, `avg_total_latency_ms`, `avg_tokens_per_second`, `avg_cost_usd`).
|
||||
- **Settings live on `users`:** three columns `ai_modifier_enabled` (0/1, default 1), `ai_modifier_sync` (0/1, default 1 = sync), and `ai_modifier_prompt` (text, default `config.DEFAULT_MODIFIER_PROMPT`), ensured in `database.backfill_api_keys()` (the user column-ensure block run by `init_db`) and seeded born-live in `utils._create_account`. **Because the feature is on by default, `backfill_api_keys()` ALSO runs a `with db:` `UPDATE users SET ... WHERE ... IS NULL` so EXISTING accounts inherit the on/sync defaults** (a freshly `create_column_by_example`-added column is NULL on existing rows, which would read as disabled - the backfill is what makes "on by default" true for everyone, not just new accounts). The `with db:` wrapper is load-bearing: a raw `db.query` write that does not commit holds the SQLite write lock. The edit route is the owner-or-admin leaf `POST /profile/{username}/ai-modifier` (`routers/profile/ai_modifier.py`, `AiModifierForm{enabled, sync, prompt}`, audit key `profile.ai_modifier`). The owner-only values are exposed on the profile page context and `ProfileOut` (`ai_modifier_enabled`/`ai_modifier_sync`/`ai_modifier_prompt`, gated by `is_owner`); `modifier_usage` is built by `routers/profile/usage._modifier_usage(uid, include_cost=viewer_is_admin)` (via the shared `_usage_view`) for `is_owner or viewer_is_admin`, surfacing the sums plus the performance averages (avg tokens/call, avg latency, avg tokens/sec, total time) like `correction_usage`; the dollar `cost_usd` and `avg_cost_usd` are present only when `viewer_is_admin`, in both HTML and JSON. The UI block lives in `profile.html` (owner-only: enable checkbox, **Apply mode** select defaulting to sync, prompt textarea, plus the usage card reusing the `.correction-usage-*` classes with the extra performance tiles) wired by `static/js/AiModifier.js` (`app.aiModifier`). Devii drives it via the owner-scoped `ai_modifier_get`/`ai_modifier_set` tools (`services/devii/ai_modifier/`, `handler="ai_modifier"`, `requires_auth=True`, not confirm-gated; `ai_modifier_set` accepts `enabled`, optional `sync`, optional `prompt`).
|
||||
|
||||
## Background services base machinery (`services/base.py`, `services/manager.py`)
|
||||
|
||||
`devplacepy/services/` provides a generic framework for running background async services alongside the FastAPI server. `BaseService` provides the async run loop, a `deque(maxlen=20)` log buffer, and graceful cancellation; `ServiceManager` is a singleton that registers, starts, and stops services. Services are fully managed from the **Services admin tab** (`/admin/services`): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing `run_once`.
|
||||
|
||||
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: `NewsService` (`services/news/`, see `devplacepy/services/news/CLAUDE.md`), `GatewayService` and provider/model routing (`services/openai_gateway/`, see `devplacepy/services/openai_gateway/CLAUDE.md`), `DeviiService` (`services/devii/`, see `devplacepy/services/devii/CLAUDE.md`), and the bot fleet service (`services/bot/`, see `devplacepy/services/bot/CLAUDE.md`).
|
||||
|
||||
### DB-backed state (correct across workers)
|
||||
|
||||
In production (`make prod`, 2 workers) only the lock-holding worker runs services, but an admin request can hit either worker. State therefore lives in the DB, not process memory:
|
||||
- **Desired/config state** in `site_settings` (via `get_setting`/`set_setting`): `service_<name>_enabled` (`"0"`/`"1"` - also the boot flag), `service_<name>_command` (`"<verb>:<counter>"`, verbs `run`/`clear`), `service_<name>_log_size`, plus each declared config field's own key.
|
||||
- **Observed state** in the `service_state` table (one row per service): `status`, `last_run`, `next_run`, `started_at`, `heartbeat`, `logs` (JSON), `updated_at`. Written by the supervising worker, read by any worker.
|
||||
|
||||
`main.py` registers every service in **all** workers (so `describe_all()` works anywhere) but only the lock worker calls `service_manager.supervise()`.
|
||||
|
||||
### `ConfigField` (`services/base.py`)
|
||||
|
||||
Declarative parameter spec. `type` in `int`/`str`/`url`/`text`/`password`/`bool`/`select`. `coerce(raw)` validates and types (raises `ValueError`, never silent); `read()` is the lenient runtime reader (falls back to `default`); `spec()` renders the field for the template/JSON (secrets masked, never sent). Optional `minimum`/`maximum`/`options`/`help`/`secret`. `group="..."` lets the detail page render fields as sections (built-in fields are grouped "General"/"Advanced").
|
||||
|
||||
### `BaseService` (`services/base.py`)
|
||||
|
||||
Abstract class for all services:
|
||||
- **`name`**, **`interval_key`** (default `service_<name>_interval`; a subclass may point it at an existing key), **`min_interval`**.
|
||||
- **`config_fields`** - class-level list of `ConfigField`. The framework prepends built-in `enabled` and interval fields and appends `log_size`; `all_fields()` returns the full set.
|
||||
- **`get_config()`** returns a typed dict from settings; **`is_enabled()`**, **`current_interval()`** (floored to `min_interval`).
|
||||
- **`log(message)`** - writes to `log_buffer` and standard `logging`.
|
||||
- **`run_once()`** - abstract; override with actual work. Read parameters via `self.get_config()`.
|
||||
- **Reconciling loop** (`_run_loop`/`_tick`, ~1s tick): honors `enabled`, runs `run_once()` when due or on a `run` command, handles `clear`, rebuilds the log deque if `log_size` changed, and persists observed state (throttled, force on transitions). Start/stop/run/clear from any worker are just DB writes the loop reacts to within ~1s - services are never hard-restarted.
|
||||
- **Lifecycle hooks** `async on_enable()` / `async on_disable()` - called on transition to enabled / disabled (and `on_disable` on shutdown). Override for long-running resources that outlive one `run_once` (e.g. the bot fleet); `run_once` then becomes a periodic reconcile/metrics tick.
|
||||
- **`collect_metrics() -> dict`** - generic live metrics. Return `{"stats": [{"label", "value"}], "table": {"columns": [...], "rows": [[...]]}}`. Persisted to `service_state.metrics` each tick and rendered live in the Services tab (stat cards + table) by `ServiceMonitor.js`. Default returns `{}`.
|
||||
- **`default_enabled`** class flag (default `True`) - set `False` for opt-in services so they never auto-start on boot (the bots service uses this).
|
||||
- **`title` / `description`** class attrs - shown in the admin UI; every service should set a `description` of what it does.
|
||||
|
||||
**Admin UI** (`routers/admin/services.py`): `/admin/services` is a slim **index** (`services.html`, an `admin-table` of name/description/status/uptime/last-run/next-run/interval) where each row links to `/admin/services/{name}`. The **detail** page (`service_detail.html`) holds everything for one service - header with controls (start/stop/run/clear), and `[data-tabs]` tabs **Overview** (meta + metrics), **Configuration** (the config form rendered as a `<fieldset>` per `field_groups` entry), **Logs**. `ServiceMonitor.js` is dual-mode (polls `/admin/services/data` for the index, `/admin/services/{name}/data` for the detail, via the shared `Poller`; its config-save POST goes through `Http.sendForm`) and shares one `update(root, svc)` over `data-*` hooks; tabs use the generic reusable `static/js/Tabs.js` (`[data-tabs]`/`[data-tab]`/`[data-tab-pane]`, wired in `Application.js`). Adding a service still needs zero UI code.
|
||||
|
||||
- **`describe()`** - builds the dict the UI consumes: `title`, `description`, derived display status (`stopped` when disabled, `running` with fresh heartbeat, `stalled` when enabled but heartbeat older than `STALE_SECONDS`), meta, logs, metrics, flat `fields`, and `field_groups` (ordered `{name, fields}` sections) read from `service_state` + settings.
|
||||
|
||||
### `ServiceManager` (`services/manager.py`)
|
||||
|
||||
Singleton that manages all registered services:
|
||||
- `register(service)` - add a service
|
||||
- `describe_all()` -> `list[dict]` (per-service `describe()`)
|
||||
- `set_enabled(name, bool)` - Start/Stop (writes the enabled flag)
|
||||
- `send_command(name, "run"|"clear")` - one-shot command channel
|
||||
- `save_config(name, form)` - strict validation via each `ConfigField`; blank secret = keep existing; returns `{ok, errors}`
|
||||
- `supervise()` - start the reconciling task per service (lock worker only)
|
||||
- `shutdown_all()` - cancel tasks on server shutdown
|
||||
|
||||
### Adding a new service
|
||||
|
||||
1. Create `devplacepy/services/your_service.py` with a class extending `BaseService`. Declare `config_fields` (and set `interval_key`/`min_interval` if reusing an existing key); read them in `run_once` via `self.get_config()`.
|
||||
2. Override `async def run_once(self) -> None`.
|
||||
3. Register in `main.py` startup event (outside the lock block, so all workers know the roster):
|
||||
```python
|
||||
from devplacepy.services.your_service import YourService
|
||||
service_manager.register(YourService())
|
||||
```
|
||||
4. The service appears automatically on `/admin/services` with start/stop, enable-on-boot, run-now, a generic config form, clear-logs, live status, and the log tail - no template, JS, or router changes needed.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
devplace news clear # Delete all news from local database
|
||||
devplace devii reset-quota <username> # Reset one user's rolling 24h AI quota
|
||||
devplace devii reset-quota --guests # Reset every guest quota
|
||||
devplace devii reset-quota --all # Reset every quota (users and guests)
|
||||
```
|
||||
|
||||
## Multi-worker concurrency (preferred rules)
|
||||
|
||||
`uvicorn --workers N` = N independent processes sharing only the filesystem and SQLite DB. Module-global caches/counters are per-process, so a local `clear()` is invisible to siblings. Full reference: admin docs `Production -> Multi-worker and concurrency` (`templates/docs/production-concurrency.html`). Enforce these:
|
||||
|
||||
- **In-process caches are never authoritative across workers.** Coordinate invalidation through the `cache_state` version table in `database.py`: call `bump_cache_version(name)` at every write path and `sync_local_cache(name, cache)` before every read path. Existing names: `auth` (guards `_user_cache`; bumped by `clear_user_cache`/`clear_session_cache` on logout, ban, role/password change), `settings` (guards `_settings_cache`; bumped by `set_setting`/`clear_settings_cache`), `relations` (`_relations_cache`), `customizations` (`_customizations_cache`), `notif_prefs` (`_notification_prefs_cache`), `gateway_routing` (`_ROUTING_CACHE`), and **`admins`** (guards `_admins_cache`, which memoizes `get_admin_uids()` / `get_primary_admin_uid()`). The `_cache_version_cache` (`ttl=1`) caches the version reads themselves, so the bump is seen by other workers within ~1s on their next read (the originating worker is immediate); the version read/bump fail open (logged, never raise). When you add a per-process cache whose staleness matters, give it a name and wire both calls - do not invent a second invalidation mechanism.
|
||||
- **Admin-set cache invariant (load-bearing).** `_admins_cache` makes `get_admin_uids()` / `get_primary_admin_uid()` (hit on the projects-listing visibility filter, `_owner_is_admin`, and every primary-admin gate: `/dbapi`, backup-archive download) a dict lookup instead of a per-call `SELECT`. It is NOT the authorization gate - `is_admin(user)`/`require_admin` read the role off the user object (independently invalidated via `clear_user_cache`), so a stale admin set cannot grant access. But **any code that writes `users.role` MUST call `database.invalidate_admins_cache()`** (clears local + bumps the `admins` version), exactly like the soft-delete and `with db:` rules. Current role-write sites all do: `routers/admin/users.py` (role change), `cli.py` (`role set`), and `utils._create_account` (first user -> Admin). Omitting the call leaves the admin set stale for up to the 300s TTL.
|
||||
- **Deterministic / eventual caches skip version-sync on purpose.** Two caches need no `cache_state` name because correctness does not depend on cross-worker freshness: (1) `docs_prose._render_markdown` is an `@lru_cache` on the **static** prose source string (pure function of template content, so identical on every worker; changes only on deploy), and (2) `main._home_cache` (`TTLCache ttl=60`) memoizes the guest `/` blocks - the featured-news block (identical for everyone) and the latest-posts block **only for the no-block case**; a viewer with a non-empty block set bypasses the cache and is computed fresh, so the original block-filter semantics are preserved byte-for-byte and there is zero cross-user leakage. Worst case is a soft-deleted/edited public post lingering on `/` for <=60s. Use a plain `TTLCache`/`lru_cache` (no version name) ONLY when the value is deterministic or its staleness is purely cosmetic; anything whose staleness affects correctness or permissions MUST use the version-sync pattern above.
|
||||
- **Authoritative state lives in the DB**, memory is only a short-TTL accelerator (sessions, the Devii 24h cap via `devii_usage_ledger`, cost counters).
|
||||
- **Global rates/quotas are worker-count-aware or DB-backed.** The rate limiter enforces `ceil(limit / DEVPLACE_WEB_WORKERS)` per process so the aggregate matches the configured value with no per-request write. `DEVPLACE_WEB_WORKERS` MUST equal the real `--workers` (set in `make prod`=2 and the Dockerfile=2; default 1 for dev). Update it whenever you change worker count.
|
||||
- **First-boot side effects are flock-serialized and idempotent.** `init_db()` runs under a blocking `flock` on `devplace-init.lock` (the seed-if-missing inserts would otherwise race to duplicate rows); `ensure_certificates()` early-returns when keys exist and otherwise generates under `.vapid.lock`. Any new run-once-at-startup work follows the same pattern (flock + exists-check / `INSERT OR IGNORE`).
|
||||
- **Run-once background work stays behind the services lock**, never per-worker: gate it on `service_manager.owns_lock()` (held via `devplace-services.lock`), as news/bots/Devii hub do.
|
||||
|
||||
## Performance caches (request-path)
|
||||
|
||||
Read-mostly aggregates that were recomputed per request or per vote sit behind short per-worker `TTLCache`s (`devplacepy/cache.py`). These are DISPLAY caches: staleness up to the TTL is accepted by design, so none of them uses the cross-worker `cache_state` versioning (that is reserved for correctness-critical caches like auth/settings/admins). The registry:
|
||||
|
||||
| Cache | Where | Key | TTL | Invalidation |
|
||||
|---|---|---|---|---|
|
||||
| `_authors_cache` | `database/ranking.py` | `ranked`/`rank_map` | 15s | TTL only - `update_target_stars` deliberately does NOT clear it per vote anymore (the old per-vote `clear()` forced a full UNION-JOIN ranking recompute on the next feed/leaderboard/landing request and never propagated cross-worker anyway) |
|
||||
| `_stars_cache` | `database/ranking.py` | user uid | 15s | TTL + `clear_user_stars(owner_uid)` from `content.apply_vote`, so a user's own total updates immediately on the voting worker |
|
||||
| `_leaderboard_cache` | `services/game/store/farm.py` | `top:{limit}` | 15s | TTL only (the farm scan + Python `farm_score` sort runs at most once per 15s per worker) |
|
||||
| `_projects_cache` | `templating.py` | user uid | 10s | TTL + `clear_user_projects_cache(uid)` from the `content.py` project create/delete choke points (in-function import - `templating` imports `content` at module level). `jinja_user_projects` also filters `deleted_at=None` now (the composer dropdown previously listed soft-deleted projects) |
|
||||
|
||||
Rules: a new hot read-path aggregate follows this exact pattern (module-level `TTLCache`, 10-15s, `clear_*` helper only when a same-worker write must be visible immediately); never reintroduce a whole-cache `clear()` on a per-event write path; profile `projects`/`gists` loads are tab-gated in `routers/profile/index.py` (`tab == X or wants_json(request)` - the JSON `ProfileOut` keeps serializing both, HTML tabs that do not render them get `[]`).
|
||||
|
||||
## Live view relay (`services/live_view_relay.py`)
|
||||
|
||||
`LiveViewRelayService` is a second lock-owner pub/sub bridge (default-enabled, 1s interval, registered in `main.py` after `NotificationRelayService`) that replaces the per-client HTTP polling on the admin live views with server push, **computing a snapshot only for topics that currently have subscribers**. Each tick it reads `pubsub.topics()` (the hub's live subscription set, which converges on the lock owner), matches each concrete subscribed topic against the `VIEWS` registry (a list of `(compiled_regex, compute_callable, min_interval_seconds)`), throttles per topic via a `time.monotonic()` map, computes the payload, and publishes it on the same topic. The compute callables (async, lazy-importing to avoid cycles, defensive try/except -> skip) reproduce the **exact payload shape the matching HTTP endpoint already returns**, so the frontend render code is unchanged - only the data-arrival path is added. Registry:
|
||||
|
||||
| Topic | Cadence | Payload (same as endpoint) |
|
||||
|-------|---------|----------------------------|
|
||||
| `container.list` | 4s | `{instances}` (admin all-instances, `_decorate`) |
|
||||
| `project.{slug}.containers` | 3s | `{instances}` (per-project, slug resolved via `resolve_by_slug`) |
|
||||
| `container.{uid}.detail` | 4s | `{instance, events, schedules, stats, runtime}` |
|
||||
| `container.{uid}.logs` | 3s | `{logs}` (backend log tail 400) |
|
||||
| `fleet.bots` | 2s | bot frames payload (`routers/admin/bots._frames_payload`) |
|
||||
| `admin.services` | 5s | `{services}` (`service_manager.describe_all()`) |
|
||||
| `admin.services.{name}` | 5s | `{service}` |
|
||||
| `admin.ai-usage.{hours}` | 15s | `build_analytics(hours)` (hours parsed from the topic) |
|
||||
| `admin.backups` | 8s | `routers/admin/backups._dashboard(can_download=False)` (storage, backups, schedules, metrics) |
|
||||
|
||||
All these topics are admin-only by pub/sub policy (non-`public`, non-`user.{uid}` -> `privileged` required), matching the admin-only pages. Frontend monitors (`ContainerInstance`, `ContainerList`, `ContainerManager`, `BotMonitor`, `ServiceMonitor`, `AiUsageMonitor`, `BackupMonitor`) each `window.app.pubsub.subscribe(topic, render)` in their init and keep a **lengthened HTTP poll (15-30s) as initial-load + fallback** - the relay drives liveness at the cadence above. `AiUsageMonitor` re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
|
||||
|
||||
**Container topics never broadcast private-project instances**: `container.list` publishes only public-project rows with `partial: true` (`ContainerList.merge` updates by uid, never removes, so private rows from the authoritative HTTP poll survive), and `project.{slug}.containers` / `container.{uid}.detail` / `container.{uid}.logs` skip private-project targets entirely (owners fall back to their HTTP polls).
|
||||
|
||||
**`admin.backups` is the one view with a per-viewer field:** the backup `download_url` is restricted to the primary administrator at every endpoint, so the relay publishes the snapshot with `can_download=False` (the bus broadcasts one payload to every admin subscriber, and is therefore treated as another endpoint that must withhold it). `BackupMonitor` derives `canDownload` from its authoritative per-viewer HTTP poll only (never from a pushed frame) and builds the `/admin/backups/{uid}/download` URL client-side; the download route itself stays primary-admin-gated.
|
||||
|
||||
To add a new admin live view: add one `(regex, compute, interval)` row to `VIEWS` whose compute returns the endpoint payload, and have the frontend subscribe to the topic while keeping a fallback poll. Reuse this subscriber-gated snapshot pattern for any future "several admins watch a periodically-recomputed server snapshot" surface; keep durable/ordered/stateful/raw-I/O channels (messages, Devii, SEO/DeepSearch progress, container PTY) OFF pub/sub. See `pubreport.md` for the full migration analysis.
|
||||
|
||||
## Notification relay (`services/notification_relay.py`)
|
||||
|
||||
**Live toasts ride the `in_app` channel** (no new channel). `NotificationRelayService` is a lock-owner `BaseService` (registered in `main.py`, default-enabled, 1s interval) modeled on `MessageRelay`: it primes a watermark to `MAX(notifications.id)` on first tick, then each tick selects `notifications WHERE id > watermark` and publishes `{uid, type, message, target_url}` to the per-recipient pub/sub topic `user.{user_uid}.notifications`. It runs **only on the service lock owner**, which is exactly where every pub/sub WS subscriber converges (non-owner `/pubsub/ws` closes `4013`), so the in-process `services.pubsub.publish` reaches the recipient's browser. Because the `notifications` row exists only when the `in_app` channel is enabled, a live toast fires **exactly** for the notifications a user has enabled - the relay needs no preference lookup of its own. The watermark always advances (boot-priming + every-tick) so a newly-connected subscriber never gets a backlog flood.
|
||||
|
||||
**Frontend:** `base.html` exposes the viewer uid as `<body data-user-uid>`, `static/js/LiveNotifications.js` (`app.liveNotifications`, constructed with `this.pubsub`/`this.toast`) subscribes to that topic and calls `app.toast.show(message, {type:"info", ms, url})`. **The toast click reproduces a bell-item click exactly:** it targets `GET /notifications/open/{uid}` (which marks the row read and redirects to its `target_url`), not the bare `target_url` - the relay publishes `uid` for this. `AppToast` gained a generic click action via `_action(options)`: `options.onClick` (a function) or `options.url` (navigate); either adds the `.dp-toast-link` pointer cursor, so any caller can attach a click action. DMs toast too (type `message` is not excluded). Reuse this relay-on-the-lock-owner pattern for any future "live mirror of a DB-persisted, per-user event."
|
||||
|
||||
**Unread-count badges ride the same relay.** The same `NotificationRelayService` tick, after publishing the toast rows, also publishes the recipient's fresh unread counts `{notifications, messages}` to `user.{uid}.counts` (computed with a direct DB query on the lock owner, never the per-worker `_unread_cache`, which could be stale there). Because a new DM also inserts a `message`-type notification row through `persist_message -> create_notification`, this one publish point covers both new notifications and new messages, so the header badge bumps instantly. `static/js/CounterManager.js` (`app.counters`, constructed with `this.pubsub`) subscribes to `user.{uid}.counts` and applies the pushed counts; its HTTP poll of `/notifications/counts` stays as a 60s reconciliation fallback (covers decrements on read and any missed frame). Read events still clear the per-worker cache as before.
|
||||
|
||||
## Online presence (`services/presence.py`, `services/presence_relay.py`)
|
||||
|
||||
Online status is a single **`users.last_seen`** UTC-ISO column (ensured in `database.backfill_api_keys`), never a new table and never a per-request insert. The design is dictated by the multi-worker architecture: a profile of user X renders on **any** worker, so "is X online" needs cross-worker state, and pub/sub is in-process only (a non-owner worker cannot publish to the lock owner), leaving SQLite as the sole shared medium. So presence is a heavily-throttled in-place UPDATE.
|
||||
|
||||
**Write path (all workers):** `main.py`'s `track_presence` HTTP middleware resolves the cached current user on every non-`/static`, non-`/avatar` request and calls `presence.touch(uid)`. `touch` keeps a per-worker in-memory `_last_write: dict[uid -> monotonic]` and writes `users.last_seen` (via `database.set_last_seen`) only when the last write for that uid is older than `config.PRESENCE_WRITE_SECONDS` (= `PRESENCE_TIMEOUT_SECONDS // 2`). So continuous browsing is a dict lookup; a write happens at most ~once per half-window per active user per worker, and the row is updated in place (zero growth). It deliberately does **not** call `clear_user_cache` (that would defeat the 300s auth cache; the stale cached self-row is irrelevant since presence of *other* users is always read from a fresh row).
|
||||
|
||||
**Read path (any worker):** `presence.is_online(user_row)` = `now - last_seen < PRESENCE_TIMEOUT_SECONDS` (env `DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60). Profile (`routers/profile/index.py` -> `profile_online`) and messages (`routers/messages.py` seed) read `last_seen` off the user row they already loaded - no extra query. Exposed as the Jinja global `is_online(user)` (`templating.py`), on `UserOut.last_seen` and `ProfileOut.profile_online`. This is the **only** cross-worker-correct approach here because pub/sub is in-process.
|
||||
|
||||
**Live path (lock owner only), change-only + hysteresis:** `PresenceRelayService` (`services/presence_relay.py`, `BaseService`, default-enabled, 2s tick, registered in `main.py`) is a sibling of `NotificationRelayService`/`LiveViewRelayService`. Each tick it recomputes ONE global online set `self._online` (dot-subscribed uids batch-read via `get_users_by_uids` + roster candidates) and drives BOTH the per-user dots and the feed roster from that one set, so they can never disagree. The set uses **hysteresis** via `presence.stays_online(elapsed, was_online)`: a user becomes online at `PRESENCE_TIMEOUT_SECONDS` but only drops after `+ PRESENCE_ONLINE_MARGIN_SECONDS` (env `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS`, default 20) - **quick to go online, slow (grace margin) to go offline** - which kills boundary flicker for a user hovering near the timeout. It publishes `{online, last_seen}` to `public.presence.{uid}` **only when a topic's `online` bool changed OR the topic is newly subscribed (first-seen)** - never on a fixed interval, so a steady page emits nothing after the initial frame (`self._published[topic] -> bool`, pruned to active topics). `public.presence.{uid}` is subscribable by any logged-in user (`pubsub/policy.py` allows `public.*`); guests fall back to the server-rendered initial state. The one-directional grace also means dots and roster stay consistent across viewers (the shared `self._online` is the single authority). To keep it lightweight the relay reads all due users in **one batched `get_users_by_uids`** per tick.
|
||||
|
||||
**Frontend:** `static/js/PresenceManager.js` (`app.presence`, constructed with `this.pubsub` in `Application.js`, mirroring `LocalTime`/`CounterManager`) scans `[data-presence-uid]` elements, subscribes each uid to `public.presence.{uid}` (deduped per uid, so a repeated author is one subscription), and treats each frame's `online` flag as **authoritative** (`entry.online`), toggling the `online` class + (for `data-presence-label` elements) the "online / last seen X / offline" text. Because the relay is change-only and authoritative, a live-subscribed dot is **not** expired by the client clock (no false-offline flicker for an active user whose `last_seen` the client cannot see advancing); the 20s `last_seen` staleness timer only applies to entries that never received a frame (guests / degraded). The window is read from `<body data-presence-timeout>`. Both the profile `.profile-presence` dot and the messages `#messages-presence` span carry `data-presence-uid`/`data-presence-last-seen`.
|
||||
|
||||
**Online-now roster (feed):** the same relay maintains ONE shared topic `public.presence.roster`, republished **only when the SET of online uids changes** (a `frozenset` compare, so pure reordering never republishes). `services/presence.py` `online_users(limit)` (strict, feed initial render) and `online_candidates(limit)` (grace window, relay hysteresis) both go through `database.get_online_users(cutoff_iso, limit)`, which reads users with `last_seen >= cutoff` via the `idx_users_last_seen` index (the one place presence is queried by `last_seen`; `config.PRESENCE_ONLINE_LIMIT`, env `DEVPLACE_PRESENCE_ONLINE_LIMIT`, default 30). **The list is ordered ALPHABETICALLY by username** (`get_online_users` `order_by=["username"]` + case-insensitive `presence.sort_by_username`), NOT by recency, so avatars keep a stable position and do not needlessly reshuffle as people's `last_seen` ticks. `routers/feed.py` puts `online_users` on the context (`FeedOut.online_users`) and `feed.html` renders the initial **Online now** panel as a `.sidebar-section` at the bottom of the left feed sidebar (`aside.sidebar-card`); `static/js/OnlineUsers.js` (`app.onlineUsers`) subscribes to `public.presence.roster` and re-renders the avatar list + count live. Roster avatars use a plain green `.presence-dot` with NO `data-presence-uid` (list membership IS the presence, so no per-user subscription - the relay drops a user from the roster when they go offline).
|
||||
|
||||
**Avatar presence dot (sitewide, DRY):** a small corner dot on **every** user avatar (green online, muted grey offline) comes from ONE reusable partial `templates/_presence_dot.html` - `<span class="presence-dot" data-presence-uid data-presence-last-seen>` guarded on `_user.get('uid')` (a partial-dict author, e.g. the issues includes, renders no dot). It carries **no** `data-presence-label`, so `PresenceManager` colours it with zero extra JS. It is included by the shared avatar partial `templates/_avatar_link.html` (its `.user-avatar-link` anchor is the positioning host, covering ~19 sites) and by the handful of raw-`<img class="avatar-img">` sites wrapped in a positioned `<span class="avatar-badge">` (the two `base.html` nav avatars, the `profile.html` hero + followers list, the `messages.html` conversation list). CSS in `static/css/base.css` (`.user-avatar-link`/`.avatar-badge` `position:relative;display:inline-flex`, `.presence-dot` sized `30%` of the avatar clamped 8-14px with a `--bg-card` ring, `.online` -> `--success`), so it is proportional and responsive at every avatar size with no per-size class. `database/follows.py` `get_follow_list` now carries `last_seen` in its trimmed dict so the followers/following dots resolve (all other author dicts are full `get_users_by_uids` rows). `dp-avatar` (`AppAvatar.js`) is docs-demo only (no real user avatars) and is intentionally out of scope. Reuse `_presence_dot.html` + the `.avatar-badge` wrapper for any new avatar surface - never hand-roll a presence dot.
|
||||
|
||||
**Messaging refactor:** the old presence was per-worker and WS-connect-based (`message_hub.is_online`/`last_seen`, `_announce_presence`, the WS `presence` frame) and broke with >1 worker. That display path was **removed**; `message_hub` keeps only its socket connection tracking for message delivery. The messages header presence is now the shared `PresenceManager`, so chat presence is finally cross-worker correct. **Never re-implement WS-connect presence** - reuse `presence.is_online`, the `public.presence.{uid}` topic, and `PresenceManager`.
|
||||
@@ -1,58 +0,0 @@
|
||||
This file documents the audit log subsystem. Claude Code auto-loads it when a file under `devplacepy/services/audit/` is read or edited.
|
||||
|
||||
## Audit log (`services/audit/`)
|
||||
|
||||
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 223 keys across 38 domains.
|
||||
|
||||
### Package layout
|
||||
|
||||
- `store.py` - the `audit_log` + `audit_log_links` tables, `ensure_tables`, `insert_event`/`insert_links`/`get_event`/`get_links`/`sweep`.
|
||||
- `categories.py` - `category_for(event_key)` (longest-prefix map).
|
||||
- `record.py` - the recorder + link builders.
|
||||
- `query.py` - `list_events`/`filter_options`/`get_event_with_links` for the admin UI (admin list/detail).
|
||||
- `service.py` - `AuditService` (retention sweep).
|
||||
|
||||
`ensure_tables()` + the 11 indexes are wired into `database.py` `init_db()` via a **local import** (audit modules import `database`/`utils`, so the import is deferred to avoid the cycle). `AuditService` is registered in `main.py`.
|
||||
|
||||
### Two recorder entrypoints (the reuse keystone)
|
||||
|
||||
`record(request, event_key, *, user=_UNSET, actor_kind=None, target_type/uid/label, old_value, new_value, summary, metadata, result="success", origin, via_agent, links, category)` is for HTTP/WebSocket handlers - `request` may be a Starlette `Request` OR `WebSocket` (both expose `.headers`/`.url`/`.client`). It auto-derives the actor from `get_current_user(request)` (or an explicit `user=`), the request fields, and the **`X-Devii-Agent`** header (-> `origin=devii`, `via_agent=1`), and auto-appends the `actor` link.
|
||||
|
||||
`record_system(event_key, *, actor_kind, actor_uid, actor_username, actor_role, origin, via_agent, ...)` is for request-less contexts (rewards, notifications, the AI gateway ledger, the news/container services, Devii turns/tasks, job services, the CLI).
|
||||
|
||||
**Both are best-effort: wrapped in try/except, they NEVER raise into the caller** - the audited action is never blocked by a logging failure. `summary` is HTML-stripped and capped at 140 chars; `metadata` is JSON-serialised. Link builders (`audit.target`, `audit.parent`, `audit.author`, `audit.recipient`, `audit.project`, `audit.instance`, `audit.setting`, `audit.job`, `audit.poll`, `audit.option`, `audit.task`, ...) keep call sites terse.
|
||||
|
||||
### Emission convention
|
||||
|
||||
Emission is **explicit per mutation** - `audit.record(...)` / `record_system(...)` calls placed after the mutation succeeds, or on the guard branch with `result="denied"`/`"failure"`. DRY choke points:
|
||||
|
||||
- Posts/projects/gists create/edit/delete record inside `content.py` (`create_content_item`/`edit_content_item`/`delete_content_item`, the latter two derive the key from `target_type`).
|
||||
- Project file ops record via the `_audit_file`/`_edit`/`_fail` helpers in `project_files.py` (read-only guard -> `result="denied"`).
|
||||
- Services via `_audit_service`.
|
||||
- Containers via `audit_instance` (in `routers/projects/containers/_shared.py`) for the HTTP path, and in the Devii `actions/dispatcher.py` `_audit_mechanic` hook (after a successful `_run`) for the agent path - the two paths are disjoint (Devii calls `api.py` directly, never the routes), so there is no double counting. The dispatcher hook also emits the `devii.*` self-config mechanics (behavior/tools/tasks/lessons/customization) from one place.
|
||||
|
||||
The dispatcher's **authorization guard** (before `_run`) mirrors this with `_audit_denied`: a tool call refused for `requires_auth`/`requires_admin`/`requires_primary_admin` records a `security.authz.denied` event (`origin="devii"`, `via_agent=1`, `result="denied"`, `metadata.tool`/`reason`) so an agent-driven escalation attempt is visible in the admin Audit Log, not just the app log. This is what surfaces a non-admin's Devii probe of `admin_*`/`db_*` tools (the tool schemas are already withheld from non-admins by `tool_schemas_for`, so this fires only when the model invents a name it never received).
|
||||
|
||||
### Failures and denials are events
|
||||
|
||||
`auth.login.failure`, `auth.password.forgot_request` (unknown email), `security.rate_limit.block`, `security.maintenance.block`, `security.authz.denied` (emitted inside `require_user`/`require_admin` on the HTTP side, and inside the Devii dispatcher's `_audit_denied` for a refused agent tool call), self-role-change and self-disable denials, **admin-seniority denials** (a junior admin managing a senior admin), read-only write attempts, and `ai.quota.exceeded` all record with `result` set to `failure`/`denied`. Auth failures, authz denials (in `require_user`/`require_admin`), rate-limit/maintenance blocks, self-role-change/self-disable denials, and quota blocks are recorded with `result` set.
|
||||
|
||||
### Admin UI
|
||||
|
||||
`GET /admin/audit-log` (paginated, filterable list, `admin_section="audit-log"`) and `GET /admin/audit-log/{uid}` (detail + links) in the `routers/admin/` package, both `require_admin`, both negotiate via `respond(..., model=AuditLogOut|AuditEventOut)`. Templates `admin_audit_log.html` / `admin_audit_event.html` reuse `admin.css` + a small `audit.css`; `_pagination.html` gained an optional backward-compatible `pagination_query` prefix (default empty) so filters survive paging. Sidebar link sits last, before Settings.
|
||||
|
||||
### Devii access (read-only)
|
||||
|
||||
Admins query the same two routes conversationally via the admin-only Devii tools `audit_log` (GET `/admin/audit-log`) and `audit_event` (GET `/admin/audit-log/{uid}`) (`services/devii/actions/catalog.py`, `handler="http"`, `requires_admin=True`) - no new endpoint, since the `respond(...)` routes already serve JSON to Devii's `Accept: application/json` client (like `admin_list_users`). `audit_log` forwards every filter as a query param (`page`, `event_key`, `category`, `actor_role`, `actor_uid`, `origin`, `result`, `q`, `date_from`, `date_to`) and returns `AuditLogOut`, whose `options` object lists the valid values for each filter so the agent can discover them in one call. `audit_event` returns `AuditEventOut` (the row + related links). A system-prompt steer in `agent.py` (the AGGREGATES block) routes audit/history/"who did X" questions to these tools.
|
||||
|
||||
### Deferred persistence
|
||||
|
||||
`record`/`record_system` do all the cheap prep (actor resolve, sanitize, `json.dumps`, link assembly) on the request thread, then hand the two DB inserts to the **background task queue** (`services/background.py`, `background.submit(_persist, row, links)`) so the request returns without waiting on SQLite. The `uid`/`created_at` are generated eagerly at record time (so the return value and the audit timestamp reflect the action, not the flush). Under `DEVPLACE_DISABLE_SERVICES=1` (tests) the queue runs inline, so audit rows are visible immediately. See CLAUDE.md -> "Background task queue".
|
||||
|
||||
### Retention
|
||||
|
||||
`AuditService` (the `audit` service, daily) prunes rows older than `audit_log_retention_days` (default 90; `0` disables) via `store.sweep`. Configurable on the Services page like any other service.
|
||||
|
||||
### Adding an event
|
||||
|
||||
Pick/extend a key in `events.md`, add the `category_for` prefix if it is a new domain, then call `audit.record`/`record_system` at the mutation point with the right links and `result`. Never gate the audited action on the recording.
|
||||
@@ -32,7 +32,6 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"proxy": "ingress",
|
||||
"seo": "tools",
|
||||
"deepsearch": "tools",
|
||||
"isslop": "tools",
|
||||
"ai": "ai",
|
||||
"database": "database",
|
||||
"pubsub": "pubsub",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
This file documents the Backup service (`devplacepy/services/backup/`, `devplacepy/services/jobs/backup_worker.py`, `devplacepy/routers/admin/backups.py`). Claude Code loads it automatically whenever a file under `devplacepy/services/backup/` is read or edited.
|
||||
|
||||
## Overview
|
||||
|
||||
Admin-only, enterprise-grade backups built on the **same async-job pattern as zip/fork** (see `devplacepy/services/jobs/CLAUDE.md`), so nothing runs on the request path. `BackupService(JobService)` (kind `backup`) is registered in `main.py` and overrides `run_once` to call `super().run_once()` (reap/refill/sweep) and then `_fire_due_schedules()`.
|
||||
|
||||
## Targets and worker
|
||||
|
||||
- **Targets** (`store.BACKUP_TARGETS`): `database` (consistent SQLite snapshot of the main DB + `devii_tasks.db` + `devii_lessons.db` via the `sqlite3` online backup API, so it is consistent under WAL - never a raw file copy), `uploads` (`UPLOADS_DIR`), `keys` (`KEYS_DIR`), `full` (database snapshot + uploads + keys; regenerable/volatile dirs - staging, locks, zips, container workspaces, chroma - are intentionally excluded). `service._materialize(target, staging)` returns a list of `{root, path}` sources; DB targets are snapshotted into `staging/db_snapshot` first.
|
||||
- **Worker** (`services/jobs/backup_worker.py`, stdlib only): reads a JSON spec `{sources:[{root,path}]}` and an output path, builds a deterministic `tar.gz` (uid/gid 0, symlinks skipped) rooted at each `root/`, and returns `{bytes_in, bytes_out, file_count, dir_count, sha256}`. Invoked via `asyncio.create_subprocess_exec` like `zip_worker`.
|
||||
|
||||
## Storage and data model
|
||||
|
||||
- **Storage:** archives go under `config.BACKUPS_DIR` (`data/backups/`, in `DATA_PATHS`) sharded with `attachments._directory_for` on the **random uuid tail** (same load-bearing reason as zips/blobs), named `{target}-{YYYYMMDD-HHMMSS}-{tail}.tar.gz`. Staging is `config.BACKUP_STAGING_DIR` (`data/backup_staging/`), removed in `process` `finally`.
|
||||
- **Data model** (`store.py`, ensured in `database.init_db` via `backup_store.ensure_tables()`): `backups` (NOT soft-deletable - an archive is a reclaimable operational artifact, hard-deleted like zips) and `backup_schedules` (in `SOFT_DELETE_TABLES`, born-live `deleted_at:None`). `store` holds all CRUD plus `compute_storage_stats()` (du of every major data area + `shutil.disk_usage`, run in `asyncio.to_thread` from the route, 30s in-process TTL cache so the walk never blocks).
|
||||
- **Permanent artifact:** `cleanup(job)` only removes leftover staging, NEVER the archive. Job retention prunes the `jobs` row; the archive and `backups` row persist until an admin deletes it, a schedule rotates it out (`keep_last`), or `devplace backups clear`. Deleting a backup is a HARD delete (unlink file + delete row) - correct because backups are GC artifacts, the documented exception to the soft-delete rule.
|
||||
|
||||
## Schedules
|
||||
|
||||
`backup_schedules` carry `kind` (`interval`|`cron`), `every_seconds`/`cron`, `enabled`, `keep_last`, `next_run_at`, run bookkeeping. `_fire_due_schedules` (lock-owner only, so each fires once) compares `next_run_at <= to_iso(now_utc())` and enqueues a `backup` job + a `backups` record, then advances `next_run_at` via `schedule.next_run`. **Timestamp format is load-bearing:** schedule `next_run_at` uses the devii `schedule.to_iso` format (`%Y-%m-%dT%H:%M:%S`, no tz/micros) on BOTH sides of the comparison so lexicographic compare equals chronological - do not mix it with `datetime.isoformat()`.
|
||||
|
||||
## Routes and frontend
|
||||
|
||||
- **Routes** (`routers/admin/backups.py`, all `require_admin`, mounted via `admin/__init__`): `GET /admin/backups` (dashboard, `respond(..., model=BackupDashboardOut)`), `GET /admin/backups/data` (JSON dashboard for the monitor + Devii), `POST /admin/backups/run`, `GET /admin/backups/jobs/{uid}` (`BackupJobOut` for `JobPoller`), `POST /admin/backups/{uid}/delete`, `GET /admin/backups/{uid}/download` (`FileResponse`, path-guarded against `BACKUPS_DIR`), `GET /admin/backups/{uid}` (`BackupOut`), and schedule CRUD `schedules/create|{uid}/edit|toggle|run|delete`. **Route order:** every literal sub-path (`data`, `run`, `jobs/{uid}`, `schedules/...`) is declared BEFORE the `{uid}` catch-alls.
|
||||
- **Frontend:** `static/js/BackupMonitor.js` (`window.BackupMonitor`, started inline from `admin_backups.html` like `AiUsageMonitor`) polls `/admin/backups/data` every 8s and renders storage/backup/schedule sections; run uses `Http.send` + `JobPoller`; delete/toggle/run/schedule actions use delegated clicks + `Http.send`; the schedule modal (create/edit) reuses the shared `.modal-overlay`/`data-modal` pattern. CSS `static/css/backups.css`. Sidebar link in `admin_base.html` (`admin_section == 'backups'`).
|
||||
- **Devii** (all `requires_admin=True`, `handler="http"`): `backups_overview`, `backup_run`, `backup_status`, `backup_delete` (+confirm, in `CONFIRM_REQUIRED`), `backup_schedule_create`, `backup_schedule_delete` (+confirm). **CLI:** `devplace backups list|run <target>|prune|clear`. **Audit:** `job.backup.complete|failed` (category `backup`), `admin.backup.run|delete`, `admin.backup_schedule.create|update|toggle|delete` (category `admin`). **Docs:** admin API group endpoints + admin prose page `backups`.
|
||||
|
||||
## Download restricted to the primary administrator (load-bearing)
|
||||
|
||||
A backup archive contains the whole database, uploads, and VAPID keys, so `GET /admin/backups/{uid}/download` is restricted beyond `require_admin` to the **primary administrator** - the earliest-created user who currently holds the Admin role (the founder; `utils._create_account` auto-promotes the first registered user). The keystone is `database.get_primary_admin_uid()` (`SELECT uid FROM users WHERE role='Admin' ORDER BY created_at ASC, id ASC LIMIT 1`; users have no `deleted_at`, do NOT filter it) and `utils.is_primary_admin(user)` (admin AND `uid == get_primary_admin_uid()`, also a Jinja global). The download endpoint records `security.authz.denied` and raises `403` for any other admin. The `download_url` field is gated identically at every emission point (`_download_url`/`_backup_payload`/`_job_payload`, threaded `can_download = is_primary_admin(admin)`), so `GET /admin/backups/data`, `/jobs/{uid}`, and `/{uid}` never expose the URL to a non-primary admin; `BackupDashboardOut.can_download_backups` carries the flag. **Client:** `BackupMonitor.js` reads `data.can_download_backups`; a `done` backup renders an enabled `<a>` Download only for the primary admin, otherwise a **disabled** `<button>` with `title="Not available"`. The disabled state cannot be bypassed - the URL is never sent and the endpoint 403s regardless. If the founder is demoted/removed the crown passes to the next-oldest admin. Creating/running/deleting/scheduling backups stay open to all admins.
|
||||
|
||||
No restore into a live server (by design - overwriting the DB/uploads while running risks corruption). Restore is a manual ops procedure documented on the `backups` docs page. The live view relay's `admin.backups` topic publishes with `can_download=False` unconditionally (see `devplacepy/services/CLAUDE.md` -> Live view relay) - `BackupMonitor` derives real download capability only from its authoritative HTTP poll.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Bot fleet (`devplacepy/services/bot/`)
|
||||
|
||||
This file documents the Playwright-driven AI persona fleet. Claude Code auto-loads it when a file under `devplacepy/services/bot/` is read or edited.
|
||||
|
||||
## Overview
|
||||
|
||||
`BotsService` (`services/bot/`) is a Playwright-driven fleet of AI personas that browse and interact with a DevPlace instance (posts, comments, votes, gists, projects, issues, follows, messages). It is the former standalone `dpbot.py` refactored into a package and wired into the service framework. Opt-in (`default_enabled = False`). Every other AI consumer's endpoint defaults reference "news, bots, Devii guests" collectively as internal-gateway consumers (see `GatewayService`).
|
||||
|
||||
## Module layout (one responsibility per file)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `config.py` | Static constants + defaults (URLs, model, costs, personas, categories, paths) |
|
||||
| `runtime.py` | `BotRuntimeConfig` dataclass - per-run settings threaded into a bot |
|
||||
| `state.py` | `BotState` dataclass + JSON persistence (incl. the per-bot `identity` card) |
|
||||
| `llm.py` | `LLMClient` (parameterized: key/url/model/costs); content generation + quality checks + the `decide`/`generate_identity` decision engine |
|
||||
| `news_fetcher.py` | `NewsFetcher` - TTL-cached article source |
|
||||
| `browser.py` | `BotBrowser` - Playwright wrapper (human-like typing/clicking/scrolling) |
|
||||
| `registry.py` | `ArticleRegistry` - flock-based cross-bot article dedupe |
|
||||
| `bot.py` | `DevPlaceBot` - the orchestrator (sessions, action cycle, `run_forever`) |
|
||||
| `service.py` | `BotsService(BaseService)` - fleet manager + metrics |
|
||||
|
||||
## Mechanics
|
||||
|
||||
- `run_once` is a **reconcile tick**: it ensures `bot_fleet_size` bot tasks are alive (relaunching dead ones), scales the fleet up/down, and is a no-op heavy work otherwise. Each bot is an `asyncio` task running `DevPlaceBot.run_forever` in the lock worker's loop. `on_disable` cancels the fleet (each bot closes its browser on `CancelledError`).
|
||||
- All "startup parameters" of the old script are `config_fields`: `bot_fleet_size` (was `--bots`), `bot_headless` (was `--headed`), `bot_max_actions` (was `--actions`), plus `bot_base_url`, `bot_api_url` (defaults to the internal gateway), `bot_news_api`, `bot_model` (defaults to `molodetz`), `bot_api_key` (secret; falls back to `internal_gateway_key()`), `bot_input_cost_per_1m`, `bot_output_cost_per_1m`.
|
||||
- **A bot uses its own account's `api_key` for gateway calls once it has an account.** `bot_api_key` (-> `cfg.api_key`, the shared `internal_gateway_key()` fallback) is only the bootstrap credential used until the bot registers/logs in. After `_ensure_auth()` succeeds in `run_forever`, `DevPlaceBot._adopt_account_api_key()` fetches the bot's own `users.api_key` from its profile JSON (`fetch('/profile/{username}', {Accept: 'application/json'})` over the authenticated browser session, where `ProfileOut.api_key` is exposed to the owner) and switches `LLMClient.api_key` to it, persisting it in `BotState.account_api_key`. The key is read once and reused on every later session (the `LLMClient` is constructed with `state.account_api_key or cfg.api_key`, and `_raw_call` reads `self.api_key` per request so a mid-session swap takes effect on the next call). This routes each bot's gateway spend through its own user attribution (`resolve_owner()` -> `(owner_kind="user", owner_id=uid)`) instead of the shared internal key. Adoption is best-effort: a fetch failure leaves the bot on the fallback key.
|
||||
- **Behavior and pacing tuning are admin `config_fields` too**, threaded through `BotRuntimeConfig` into the bot (never read module constants directly for these). The `config.py` constants (`MAX_BOTS_PER_ARTICLE`, `ARTICLE_TTL_DAYS`, `GIST_MIN_LINES`, `ACTION_PAUSE_MIN_SECONDS`/`ACTION_PAUSE_MAX_SECONDS`, `BREAK_SCALE_DEFAULT`) are only the **defaults**; the live values come from the settings: **Behavior** group - `bot_max_per_article` (-> `ArticleRegistry.max_per_article`), `bot_article_ttl_days` (-> `ArticleRegistry.ttl_days`), `bot_gist_min_lines` (-> `LLMClient.gist_min_lines`); **Pacing** group - `bot_action_pause_min_seconds`/`bot_action_pause_max_seconds` (the intra-session pause, clamped lo/hi in `DevPlaceBot.__init__` so an inverted pair is harmless) and `bot_break_scale` (a float multiplier on the between-session break, floored at 0.1 and the result floored at 5s); **Decisions** group - `bot_ai_decisions` (-> `BotRuntimeConfig.ai_decisions`, the AI-decision kill switch) and `bot_decision_temperature` (-> `BotRuntimeConfig.decision_temperature`). To add a new tuning knob: add the default to `config.py`, a `ConfigField` to `BotsService.config_fields` (its `group` creates/joins a UI section automatically), a field to `BotRuntimeConfig`, the `cfg[...]` mapping in `_launch_slot`, and consume it via the runtime config - do not reach for the module constant at the call site.
|
||||
- **Live pricing** is published via `collect_metrics()`: fleet stat cards (bots running, fleet cost, LLM calls, tokens, posts/comments/votes) plus a per-bot table. `Fleet cost` is cumulative across restarts because each bot seeds its `LLMClient` totals from its saved `BotState`. **Cost is read from the gateway's authoritative per-call headers, not recomputed locally.** `LLMClient._account_usage(resp.headers, body_usage)` parses the `X-Gateway-*` response headers via `openai_gateway.usage.parse_usage_headers` (the shared parser, also used by `services/correction.py`) and takes `X-Gateway-Cost-USD` plus the prompt/completion token counts straight from the gateway, so the figure is cache-aware and native-cost-aware (it matches what `/admin/ai-usage` attributes to each bot's account). The `bot_input_cost_per_1m`/`bot_output_cost_per_1m` settings are **fallback-only**, applied to the response-body token counts solely when the endpoint returns no gateway headers (a non-gateway OpenAI URL); their defaults (`0.14`/`0.28`) mirror the gateway's chat pricing so even the fallback is sane. (Before this, the bots flatly multiplied every prompt token by an inflated `0.27`/`1.10` with no cache awareness, which massively overstated cost and the 24h projection.) `Cost rate` and `Projected 24h cost` come from a **sliding window**, not lifetime: `_sample_cost()` appends `(now, total_cost)` each tick into `_cost_samples`, evicts samples older than `config.COST_WINDOW_SECONDS` (600s), and measures the cost delta over the retained span; the projection is shown only once the window reaches `config.COST_WARMUP_SECONDS` (120s), otherwise both cards read "warming up". Samples reset when `_started_at` changes (`_cost_anchor`). Never project from lifetime `Fleet cost`/uptime, and never anchor the rate at startup - the boot burst (all bots ramping at once) then dominates the average and massively overstates a 24h projection extrapolated 274x from a few minutes.
|
||||
- Heavy deps (`playwright`, `faker`) are **lazily imported** inside `run_once`/`_launch_slot` (install the `bots` extra). The service registers and shows in the admin tab even when they are absent; it logs and stays idle. Per-bot state lives in `~/.devplace_bots/state_slot{N}.json`; the article registry in `~/.dpbot_article_registry.json`.
|
||||
- **Direct messages stay a dialog, never a monologue.** A bot may open a conversation, but `_compose_message` first calls `_spoke_last_in_thread()` and refuses to send when the last `.message-bubble` in `.messages-thread` is `.mine` (the bot itself sent the most recent message). So a bot only replies after the other person has spoken since its last message; it never sends two messages in a row. An empty thread (no bubbles) is allowed, so a bot can still initiate contact. This guards both reply paths (`_check_messages` and `_send_message`), mirroring how a real person waits for a reply before writing again.
|
||||
|
||||
## Live screenshot monitor (`services/bot/monitor.py`, `/admin/bots`)
|
||||
|
||||
The admin **Bot Monitor** (`/admin/bots`, sidebar link in `admin_base.html`, `require_admin`, `noindex`) shows the **latest low-quality screenshot per bot** with its username, persona, and current action/status, auto-refreshing every 2s. It is the constructive counterpart to the cost metrics: cost tells you what the fleet spent, the monitor tells you what each bot is looking at right now.
|
||||
|
||||
- **Capture is a `BotBrowser` concern** because the browser owns the Playwright `page`. `BotBrowser.bind_monitor(slot, username, persona)` (called from `DevPlaceBot.run_forever` once auth completes), `BotBrowser.note(action=, status=)`, and `BotBrowser.capture(reason, force=)` are the surface. `capture` takes a JPEG via `page.screenshot(type="jpeg", quality=MONITOR_JPEG_QUALITY=35, scale="css")`, throttled to one every `MONITOR_MIN_INTERVAL_SECONDS` (1s) unless `force=True`. **Capture is best-effort and never raises into the bot loop.** It fires at the meaningful-event choke points already centralized in `BotBrowser`: `goto` (page load, forced), `scroll`, `fill` (field input), `reload` (forced), plus `DevPlaceBot._action(tag, detail)` (every post/comment/vote/react/gist - an action-labelled forced capture via `asyncio.create_task`).
|
||||
- **Storage is in-memory + one file per bot, never accumulating.** `monitor` (the `BotMonitor` singleton) holds a `slot -> BotFrame` dataclass map of tiny metadata, and writes the JPEG bytes to `BOT_DIR/monitor/slot{N}.jpg` (`config.DATA_DIR`, OUTSIDE the package, NOT under `/static`), **overwritten atomically in place** (`.tmp` then `replace`). One frame per slot, capped at the fleet size (`bot_fleet_size`, 0 to 20). `_stop_slot` calls `monitor.drop(slot)` to evict the row + unlink the file. No DB table, no soft-delete (nothing is persisted as a row), no shard tree (one file per bot is already bounded). This is what keeps it cheap at full fleet: at most 20 small JPEGs in RAM-backed files, refreshed in place.
|
||||
- **Transport is polling, not WebSocket - deliberately.** Frames live only in the worker running the Bots service (the service lock owner), exactly like container logs/metrics. Rather than a lock-owner-gated WS with the 4013 retry, the monitor reuses the **existing `service_state` cross-worker bridge**: the lock owner publishes the frame metadata in `collect_metrics()["frames"]` (persisted to the `service_state` DB row by `BaseService._persist_state`), and ANY worker reads it back via `service_manager.get_service("bots").describe()["metrics"]["frames"]`. The page polls `GET /admin/bots/data` (`Poller`, 2s, `pauseHidden`) for that metadata and renders `<img>` tags at `GET /admin/bots/{slot}/frame.jpg` (raw bytes from the shared `BOT_DIR/monitor/` file, `Cache-Control: no-store`, served by any worker since it is a file under `DATA_DIR`). The `frame_url` carries a `?t=captured_at` cache-buster so a new frame is fetched only when it changed. This is the lightest correct option for the multi-worker model: no extra socket, no handshake, no 4013 bounce, and it reuses the same DB-backed metrics path the services page already polls. JSON shape is `schemas.AdminBotsOut`/`BotFrameOut`; Devii reads the same `/admin/bots/data` via the admin `bot_monitor` action; docs in `docs_api.py` (`admin-bots-*`, the frame endpoint in `NON_BODY_ENDPOINTS`).
|
||||
- **Frontend** is `static/js/BotMonitor.js` (`app.botMonitor`, one class, `Http` + `Poller`) rendering the `static/css/bots.css` responsive grid (`bot-active`/`bot-idle` by frame age, design tokens only). Add a new capture point by calling `self.b.capture("reason")` (or `note(...)` then capture) at the new event; do not screenshot outside `BotBrowser` (it owns the page and the throttle).
|
||||
|
||||
## Realism: titles, topic spread, gist quality, inter-bot threads, thread-aware distinct opinions
|
||||
|
||||
Five content/behaviour mechanics keep the fleet from reading as machine-generated. Treat them as the realism contract; do not regress them.
|
||||
|
||||
- **Post titles are rewritten, never the raw headline.** A post body reacts to a news article, but the title is generated separately by `llm.generate_post_title(headline, persona, category)` - a short (3 to 8 word) human title in the persona's voice, not the verbatim article headline. The raw headline is still the registry dedupe key (`clean_title`), but the typed title is `type_title` (the rewritten one, `strip_md`-capped at 120). Same applies to projects: `generate_project_title`/`generate_project_desc` take the persona and run through `strip_label`. **`LLMClient.strip_label`** removes LLM label echoes (`Title:`, `Project Name:`, `Snippet name:`, and a trailing ` Concept:`/` Description:` clause) so a title can never be `Project Name: X Concept: Y`. Run every generated title through `strip_label`.
|
||||
- **Personality drives topic and category, not just voice.** Category is chosen by `config.pick_category(persona)` (a persona-weighted `random.choices` over `PERSONA_CATEGORY_WEIGHTS`), replacing the old content classifier - a grumpy_senior rants, an enthusiastic_junior shows off and asks questions. Article selection is persona-scored: `config.persona_article_score(article, persona)` counts `SEARCH_TERMS` keyword hits, and both `_pick_article` (cache path) and `ArticleRegistry.reserve_unused` (direct path) rank articles by score plus a `random()` jitter, so different personas gravitate to different news rather than all reacting to the same headline.
|
||||
- **Gists are quality-gated and non-trivial.** `generate_gist` now writes the code first (8 to 20 lines, persona-flavoured via `PERSONA_GIST_FLAVOR`, language drawn from `PERSONA_LANGUAGES`), then titles/describes it from the code. `_create_gist` runs a two-attempt loop through `llm.gist_quality_check(title, code, language)`: a `TRIVIAL_GIST_TERMS` blocklist on the title, a minimum non-empty line count (`LLMClient.gist_min_lines`, admin `bot_gist_min_lines`, default 6), and a strict LLM judge that rejects 101-tutorial snippets. Mirrors the post/comment `quality_check` loop; a reject regenerates once then skips.
|
||||
- **Every comment is thread-aware and must hold a distinct opinion.** A bot never comments blind to what others already said. Before generating, `_comment_on_post` / `_reply_to_random_comment` call `DevPlaceBot._existing_comments()`, which scrapes the last `MAX_SIBLING_COMMENTS` (8) visible `.comment-text` bodies with their `/profile/` author (own comments excluded, each capped at `SIBLING_SNIPPET_LEN`=240). The block is threaded into `llm.generate_comment(..., existing_comments=)`, whose system prompt then forces a point none of them made - a different angle, a missed caveat, or respectful disagreement with one of them by name - and forbids repeating any opinion, framing, example, or question already raised. The mechanical backstop is in `llm.quality_check(..., siblings=)`: a candidate whose `_overlap_ratio` against the joined sibling text exceeds `SIBLING_OVERLAP_THRESHOLD` (0.55) is rejected as `echoes another comment` and regenerated (same two-attempt loop as the source-restatement gate at `RESTATEMENT_OVERLAP_THRESHOLD`=0.6). This is what stops a salient post from drawing a dozen near-identical "tipping point / feedback loop / cooldown" replies. Any new comment-generation path MUST gather siblings and pass them to both `generate_comment` and `quality_check`.
|
||||
- **Usernames read like nerd handles, not real names.** Bots do NOT sign up with faker person names; they pick devRant/Hacker-News-style handles. Two layers, LLM-first with an offline fallback: (1) `llm.generate_handle_candidates(persona)` asks the model for ~8 distinct handles grounded in the persona and its `SEARCH_TERMS` interests (tech nouns, leetspeak, adjective+noun, creatures, short word+number), each run through `handles.sanitize_handle`; the pool is cached on `BotState.handle_candidates` and consumed by `_next_handle()`. (2) `handles.make_handle(interests)` is the pure, offline algorithmic generator (curated word banks + probabilistic leetspeak + number/separator/casing decoration, persona-seeded from `SEARCH_TERMS`), used as the fallback whenever the LLM pool is empty or a signup collides. Both layers emit only `[A-Za-z0-9_-]`, 3 to 20 chars (the old `first.last` styles silently failed signup validation, which rejects dots). On a third signup retry a random number is appended to bust collisions. Word banks and tuning constants live in `services/bot/handles.py`; never reintroduce faker person-name handles.
|
||||
- **Bots engage each other, and threads deepen.** `ArticleRegistry` allows up to `max_per_article` holders per article (admin `bot_max_per_article`, default 2), each with a **distinct category** (stored as `holders: [{bot, category, time}]`; old `{bot, time}` rows are normalized on load), so two bots can post different angles on the same trending news - which gives them each other's posts to discuss. A holder ages out after `ttl_days` (admin `bot_article_ttl_days`, default 7). `_engage_community()` (called once per normal/deep session after notifications) goes to `/feed?tab=recent|trending`, ranks non-own posts with a preference for `known_users` authors, opens one, comments, and replies into the comment thread. The on-post reply-to-comment probability is also raised so multi-bot threads actually form. `reserve(title, owner, category)` is idempotent per owner and enforces the same distinct-angle / max-holders rules as `reserve_unused`.
|
||||
|
||||
## AI-driven decisions and identity cards (`bot_ai_decisions`)
|
||||
|
||||
Opt-in mechanic (off by default; full design and cost model in `aibots.md`). When `bot_ai_decisions` is on, a bot replaces the procedural action cascade with one LLM decision call per page, driven by a unique AI-generated identity. **Do not regress the grounding and the kill-switch fallback.**
|
||||
|
||||
- **Two LLM entrypoints, both on `LLMClient`.** `generate_identity(archetype)` runs once per bot (seeded from the persona archetype) and returns the identity card: `name`, `backstory`, `interests`, `dislikes`, `temperament`, and four 0.0-1.0 scalars (`verbosity`, `contrarianness`, `generosity`, `curiosity`) plus `rhythm`. `_normalize_identity` clamps every scalar and defaults `interests` to the archetype's `SEARCH_TERMS`, so a malformed model reply can never produce a broken card. The card persists on `BotState.identity` (serialised to `state_slot{N}.json`); old state files load with an empty dict and regenerate on first AI session. `decide(identity, page_state, menu, history, temperature)` returns `{plan, energy, stop_after}` (strict JSON), the per-page ordered action plan.
|
||||
- **JSON is parsed raw, never `clean()`-ed.** `_call` runs the output through `clean()` (which strips `*`/`_` and collapses whitespace, corrupting JSON keys like `stop_after`); the decision engine instead uses `_raw_call` (the extracted HTTP+cost-accounting core, no `clean`) and `_parse_json` (tolerates code fences and prose wrappers by slicing the first `{`..`}`). When adding any JSON-returning LLM method, use `_raw_call`, not `_call`.
|
||||
- **The harness supplies the options; the model only picks.** `DevPlaceBot._build_menu(page_state)` builds the action menu from the same observable-page predicates the random `_cycle` uses, listing only actions that are physically possible and policy-allowed (throttles like `can_post`/`can_project`/own-post-no-mention are enforced by *omitting* the action). `decide` filters the returned plan against that menu (`_filter_plan` drops any unknown action), re-asks once on malformed/empty output, and the executor `_run_plan` falls back to a single `navigate` to feed when the plan is still empty - **never a `random` draw**. `_dispatch_action` maps each action string to the existing per-action method (`_comment_on_post`, `_vote_for_page`, `_react_to_object`, `_navigate_to`, ...); content actions still run their `quality_check`/`gist_quality_check` gates unchanged, so quality cannot degrade.
|
||||
- **`_cycle_ai` mirrors `_cycle`'s signature and return tuple** so `run_forever` swaps between them with `(self._cycle_ai if use_ai else self._cycle)(...)`. `energy` and `stop_after` replace `_session_type` mood and `_fatigue_check`: the session ends when `session_actions >= stop_after`, the intra-action pause is `_scaled_pause()` (scaled by `energy`), and the between-session break is scaled by `energy` too. Timing jitter (`_read_like_human`, `_type_like_human`, `_idle`) stays procedural - the model decides *what*, not the millisecond cadence. `use_ai` is `self._ai_decisions and bool(self.state.identity)`; identity generation and the flag are gated so a disabled fleet pays nothing and behaves exactly as before.
|
||||
@@ -102,7 +102,7 @@ REACT_RATES = {
|
||||
}
|
||||
REACT_RATE_DEFAULT = 0.20
|
||||
|
||||
CATEGORIES = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
|
||||
CATEGORIES = ["devlog", "showcase", "question", "rant", "fun", "random"]
|
||||
|
||||
GIST_LANGUAGES = [
|
||||
"python",
|
||||
@@ -122,7 +122,7 @@ GIST_LANGUAGES = [
|
||||
"lua",
|
||||
]
|
||||
|
||||
FEED_TOPICS = ["devlog", "showcase", "question", "rant", "fun", "politics"]
|
||||
FEED_TOPICS = ["devlog", "showcase", "question", "rant", "fun"]
|
||||
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
|
||||
|
||||
SEARCH_TERMS = {
|
||||
@@ -240,7 +240,6 @@ PERSONA_CATEGORY_WEIGHTS = {
|
||||
"question": 1,
|
||||
"showcase": 1,
|
||||
"fun": 1,
|
||||
"politics": 1,
|
||||
},
|
||||
"hobbyist_maker": {
|
||||
"showcase": 3,
|
||||
@@ -257,7 +256,6 @@ PERSONA_CATEGORY_WEIGHTS = {
|
||||
"showcase": 1,
|
||||
"rant": 1,
|
||||
"fun": 1,
|
||||
"politics": 1,
|
||||
},
|
||||
"minimalist": {
|
||||
"random": 3,
|
||||
@@ -282,7 +280,6 @@ PERSONA_CATEGORY_WEIGHTS = {
|
||||
"random": 1,
|
||||
"showcase": 1,
|
||||
"devlog": 1,
|
||||
"politics": 2,
|
||||
},
|
||||
"mentor": {
|
||||
"devlog": 3,
|
||||
@@ -291,7 +288,6 @@ PERSONA_CATEGORY_WEIGHTS = {
|
||||
"random": 1,
|
||||
"rant": 1,
|
||||
"fun": 1,
|
||||
"politics": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ class BotEngageMixin:
|
||||
else:
|
||||
comment = f"@{mention_target} {comment}"
|
||||
|
||||
comment = self._sanitize_mentions(comment)[:2000]
|
||||
comment = comment[:2000]
|
||||
mention_log = f" @{mention_target}" if mention_target else ""
|
||||
|
||||
textarea_sels = [
|
||||
@@ -437,7 +437,7 @@ class BotEngageMixin:
|
||||
return False
|
||||
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
|
||||
reply = f"@{mentioner} {reply}"
|
||||
reply = self._sanitize_mentions(reply)[:2000]
|
||||
reply = reply[:2000]
|
||||
textarea_sels = [
|
||||
".reply-form textarea[name='content']",
|
||||
".comment-form textarea[name='content']",
|
||||
|
||||
@@ -11,8 +11,6 @@ from devplacepy.services.bot.config import persona_article_score, pick_category
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MENTION_RE = re.compile(r"@([A-Za-z0-9_-]+)")
|
||||
|
||||
|
||||
class BotHelpersMixin:
|
||||
def _identity(self) -> str:
|
||||
@@ -33,21 +31,6 @@ class BotHelpersMixin:
|
||||
m = re.search(r"/(?:posts|gists|news|projects)/([^/?#]+)", url)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def _sanitize_mentions(self, text: str) -> str:
|
||||
own = (self.state.username or "").lower()
|
||||
seen: set[str] = set()
|
||||
|
||||
def keep(match: "re.Match[str]") -> str:
|
||||
handle = match.group(1)
|
||||
lowered = handle.lower()
|
||||
if lowered == own or lowered in seen:
|
||||
return ""
|
||||
seen.add(lowered)
|
||||
return match.group(0)
|
||||
|
||||
cleaned = _MENTION_RE.sub(keep, text)
|
||||
return re.sub(r"\s{2,}", " ", cleaned).strip()
|
||||
|
||||
def _sync_cost(self) -> None:
|
||||
self.state.total_cost = self.llm.total_cost
|
||||
self.state.total_calls = self.llm.total_calls
|
||||
|
||||
@@ -115,10 +115,7 @@ class LLMClient:
|
||||
@staticmethod
|
||||
def clean(text: str, preserve_md: bool = False) -> str:
|
||||
if not preserve_md:
|
||||
text = re.sub(r"\*+", "", text)
|
||||
text = re.sub(r"(?<![A-Za-z0-9])__(?=\S)(.*?)(?<=\S)__(?![A-Za-z0-9])", r"\1", text)
|
||||
text = re.sub(r"(?<![A-Za-z0-9])_(?=\S)(.*?)(?<=\S)_(?![A-Za-z0-9])", r"\1", text)
|
||||
text = re.sub(r"(?<![A-Za-z0-9])_+(?![A-Za-z0-9])", "", text)
|
||||
text = re.sub(r"\*\*|__|\*|_", "", text)
|
||||
text = text.replace("—", "-").replace("–", "-")
|
||||
text = text.replace("‘", "'").replace("’", "'")
|
||||
text = text.replace("“", '"').replace("”", '"')
|
||||
@@ -196,7 +193,6 @@ class LLMClient:
|
||||
"rant": "Write it as an opinionated rant - strong viewpoint, passionate criticism, but keep it substantive.",
|
||||
"fun": "Write it lighthearted and playful, but stay tied to the actual tech topic. Humor about the technology itself, not off-topic jokes or lyrics.",
|
||||
"random": "Be natural and conversational - share your thoughts like any casual discussion.",
|
||||
"politics": "Write it as a measured take on the politics of this technology - regulation, governance, open-source licensing, industry power, or ethics. Stay substantive and non-partisan; argue the policy angle, not party lines.",
|
||||
}
|
||||
persona_extra = (
|
||||
f" {persona_extras.get(persona, 'Be casual. No markdown.')}"
|
||||
|
||||
@@ -28,7 +28,7 @@ class BotSocialMixin:
|
||||
try:
|
||||
await links.nth(i).click(timeout=5000)
|
||||
await b._idle(1.0, 2.0)
|
||||
uname = h.split("/profile/")[-1].split("?")[0].split("/")[0]
|
||||
uname = h.split("/profile/")[-1].split("?")[0]
|
||||
if uname not in self.state.known_users:
|
||||
self.state.known_users.append(uname)
|
||||
self.state.profiles_viewed += 1
|
||||
@@ -446,9 +446,6 @@ class BotSocialMixin:
|
||||
)
|
||||
except Exception:
|
||||
mentioner = ""
|
||||
if mentioner and mentioner.lower() == self.state.username.lower():
|
||||
self._log("Mention target resolves to self, skipping")
|
||||
return False
|
||||
try:
|
||||
await comment_loc.scroll_into_view_if_needed(timeout=2000)
|
||||
await b._idle(0.3, 0.9)
|
||||
@@ -488,7 +485,7 @@ class BotSocialMixin:
|
||||
return False
|
||||
if mentioner and f"@{mentioner.lower()}" not in reply.lower():
|
||||
reply = f"@{mentioner} {reply}"
|
||||
reply = self._sanitize_mentions(reply)[:2000]
|
||||
reply = reply[:2000]
|
||||
textarea_sels = [
|
||||
".comment .reply-form textarea[name='content']",
|
||||
".reply-form textarea[name='content']",
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
This file documents the Container manager subsystem (devplacepy/services/containers/, plus its routers/projects/containers and routers/admin/containers.py HTTP surface, and the shared ppy Docker image). Claude Code loads it automatically whenever a file under devplacepy/services/containers/ is read or edited.
|
||||
|
||||
## Overview
|
||||
|
||||
Admin-only. Supervises container instances, all running one shared prebuilt image. Driven through the `docker` CLI via `asyncio.create_subprocess_exec` behind a pluggable **`Backend` ABC**, never a docker SDK. Reference: admin docs `Services -> Container Manager` and the `containers` API group.
|
||||
|
||||
## Backend seam
|
||||
|
||||
`backend/base.py` defines the `Backend` ABC. `DockerCliBackend` drives `docker` via `asyncio.create_subprocess_exec`, streaming run logs line-by-line (`_stream`). `FakeBackend` is the in-memory test double (its `image_exists` returns `True`). `runtime.get_backend()`/`set_backend(b)` selects the active backend - tests call `set_backend(FakeBackend())`. A future Kubernetes/remote backend implements the same ABC. The container OS workspace mount point is the single constant `backend/base.py` `WORKSPACE_MOUNT` (`/app`).
|
||||
|
||||
## Security (load-bearing)
|
||||
|
||||
Mounting the docker socket is root-equivalent on the host. Every run/exec/lifecycle/schedule operation is gated `require_admin` (HTTP) and `requires_admin=True` (Devii). `--privileged` is never passed. User input is never shell-interpolated - every call is an arg-list subprocess, never `shell=True`. Resource limits are applied via `--cpus`/`--memory`.
|
||||
|
||||
On top of the admin gate, **per-user container isolation** applies (see root CLAUDE.md "Project visibility (is_private) and read-only" convention): only the **primary administrator** (earliest-created Admin) and the instance owner (creator or project owner) may manage an instance; other admins get read-only access, and only when the instance's project is public. Enforced via `content.py` predicates `owns_instance`, `can_view_project_containers`, `can_view_instance`, `can_manage_instance` (all reusing `is_primary_admin`): the primary administrator sees and manages every container including those on private projects; any other admin can VIEW containers of others only when the instance's project is public (private-project containers of others are invisible; of private containers they see only their own), and can MANAGE (edit/lifecycle/exec/terminal/sync/delete/schedules) only instances they own - non-owners get 403 plus an audit row with `result="denied"` (WS close `1008`). This is enforced at every entrypoint: `routers/projects/containers/` (`project_for` + `manage_guard` + the exec WS), the admin Containers manager (`_decorate` filtering + per-row `can_manage`, `_viewable_instance_or_404`, `_manage_denied`, create + project-search), the Devii container tools (`ContainerController._project` + `_require_manage`), and the live view relay (broadcast topics never carry private-project instances).
|
||||
|
||||
## One shared image, no in-app builds (load-bearing)
|
||||
|
||||
Every instance runs `config.CONTAINER_IMAGE` (default `ppy:latest`, override `DEVPLACE_CONTAINER_IMAGE`), built ONCE from `ppy.Dockerfile` via `make ppy` (context `devplacepy/services/containers/files`). There are no per-project Dockerfiles, builds, `ContainerBuildService`, or build UI - all removed.
|
||||
|
||||
`service._launch` calls `api.run_spec_for(inst, config.CONTAINER_IMAGE)`. `api.create_instance(project, *, name, ...)` fails fast with `ContainerError` if `backend.image_exists(CONTAINER_IMAGE)` is `false` ("run 'make ppy'"); it no longer takes a dockerfile/build. `backend.image_exists(ref)` (`docker image inspect`) is part of the `Backend` ABC.
|
||||
|
||||
Migrating off the old per-project-build model: `devplace containers prune-builds` removes the legacy per-project images (`backend.remove_image`) and clears the `dockerfiles`/`dockerfile_versions`/`builds` tables (one-time); existing instances auto-repoint to `ppy` (their stored `build_uid` is ignored).
|
||||
|
||||
**Default idle:** `run_spec_for` runs `["sleep", "infinity"]` when an instance has no `boot_command`, so a bare instance stays up instead of exiting (the image `CMD` is the same, but the explicit command makes it independent of the image).
|
||||
|
||||
## Data model
|
||||
|
||||
`store.py`, indexed in `init_db`:
|
||||
- `instances`
|
||||
- `instance_events` (audit trail; also home of the status-history rows written by `_set_status`)
|
||||
- `instance_metrics` (ring buffer, `METRICS_RING=720`; sweep keeps it bounded)
|
||||
- `instance_schedules` (cron/interval/once)
|
||||
|
||||
## Reconciler (`ContainerService`, `service.py`)
|
||||
|
||||
A `BaseService` reconciler; the model is desired-vs-actual, NOT a task per container. Each tick:
|
||||
1. `backend.ps(label=devplace.instance)` snapshots `docker ps` (label `devplace.instance=<uid>` is the join key).
|
||||
2. Converge each instance to its `desired_state`.
|
||||
3. Apply restart policies.
|
||||
4. Reap orphan containers (labeled but no DB row -> no orphans, no lost state).
|
||||
5. Fire due `instance_schedules` (reusing `devii/tasks/schedule.py` `cron_next`/`next_run`).
|
||||
6. Sample `docker stats`.
|
||||
|
||||
Only the service lock owner reconciles; HTTP handlers only flip `desired_state` / write events. `docker run --name <slug>` collision is the double-launch guard.
|
||||
|
||||
## Workspace materialization
|
||||
|
||||
Workspace = `/app` (the `WORKSPACE_MOUNT` constant). `project_files.export_to_dir` materializes the project to `config.CONTAINER_WORKSPACES_DIR/<project>` (`DATA_DIR/container_workspaces`, default `data/`) once, bind-mounted RW; `project_files.import_from_dir` (the inverse) syncs it back on the sync action. Both run via `asyncio.to_thread`.
|
||||
|
||||
**Runtime data must live OUTSIDE the `devplacepy/` package and NOT under `/static`**: workspaces (and zips) live in `config.DATA_DIR` (configurable via `DEVPLACE_DATA_DIR`). Never put generated data under `STATIC_DIR` - it is served publicly AND watched by dev `--reload` (scoped to `--reload-dir devplacepy`). The docker daemon must be able to bind-mount `DATA_DIR` for `/app`.
|
||||
|
||||
Every exec passes `-w /app` explicitly (`DockerCliBackend.exec` and the PTY exec in `routers/projects/containers/instances.py`), so one-shot `container_exec`, the interactive shell, and tmux sessions all start in the project workspace. The agent is told this (system prompt + `container_exec` tool summary) so it never prefixes a command with `cd /app`.
|
||||
|
||||
## Shared operations
|
||||
|
||||
`api.py` holds the operations shared by `routers/projects/containers/instances.py` (and the rest of the `routers/projects/containers/` subpackage) and the Devii `ContainerController` (`services/devii/container/`, `handler="container"`, 7 instance tools). The reconciler service defaults **disabled** (needs docker); enable it on `/admin/services`.
|
||||
|
||||
## HTTP routing surface
|
||||
|
||||
- `/projects/{slug}/containers` - the `routers/projects/containers/` subpackage: `instances.py` (creation/lifecycle/exec/logs/metrics/sync plus the exec websocket), `schedules.py` (cron/interval/once schedules), shared helpers in `_shared.py`. Every instance runs the shared `ppy` image. Discoverable from the project detail page's admin-only **Containers** button (gated by `content.can_view_project_containers` via the `viewer_can_containers` context flag) and from the admin index.
|
||||
- `/admin/containers` - `routers/admin/containers.py`: lists every instance across all projects with inline actions (start/stop/restart/terminal/edit/delete) plus a create modal (project search-select, run-as user search-select, boot language + source editor, restart policy, start-on-boot, env/ports/limits/ingress); `/admin/containers/{uid}` is the per-instance detail page (lifecycle, live logs/metrics, PTY terminal, schedules, ingress, sync, status history) and `/admin/containers/{uid}/edit` edits run-as user, boot language/script/command, restart policy, start-on-boot, and limits.
|
||||
|
||||
**Key reuse rule:** the admin Containers section adds NO lifecycle/logs/exec endpoints of its own - the instance carries its `project_uid`, so the admin detail route resolves the project and its frontend targets the existing `/projects/{slug}/containers/instances/{uid}/...` routes. Add any new instance operation to `routers/projects/containers/instances.py` only; the admin detail page picks it up for free.
|
||||
|
||||
## UI - two surfaces over one API set
|
||||
|
||||
Both are discoverable (an earlier version of the per-project page had zero links to it - fixed).
|
||||
|
||||
1. **Per-project manager**: `templates/containers.html` + `static/js/ContainerManager.js` handles instance creation through an app modal form (the `_macros.html` `modal()` macro + `ModalManager` `.visible` toggle); its instance list links out to the shared detail page. Reached from the project detail page's Containers button, and passes breadcrumbs so content clears the fixed nav.
|
||||
2. **Admin Containers section**: `routers/admin/containers.py` (mounted `/admin/containers`, sidebar link in `admin_base.html`, `admin_section="containers"`). `GET /admin/containers` lists every instance via `store.all_instances()` (decorated with project title/slug from one `projects` lookup) in an `.admin-table`. `GET /admin/containers/data` is the poll JSON. `GET /admin/containers/{uid}` renders `templates/containers_instance.html` + `static/js/ContainerInstance.js` - a dedicated detail page (lifecycle, poll logs/metrics, schedules add/delete, ingress, sync, interactive exec over a PTY WebSocket gated on the lock owner).
|
||||
|
||||
All container CSS (`static/css/containers.css`) uses app design tokens (`--bg-card`, `--text-primary`, `--success`/`--danger`/`--warning`, `--radius`) and the shared `.card` recipe.
|
||||
|
||||
## Admin CRUD manager (`/admin/containers`)
|
||||
|
||||
`/admin/containers` is a full manager, not a read-only list. `routers/admin/containers.py` adds (all `require_admin`, all reuse `api.*` directly - no docker/exec backend duplicated):
|
||||
- `POST /create` (`ContainerAdminCreateForm` -> `api.create_instance`, project search-select + run-as user + boot fields + restart policy + start_on_boot + env/ports/limits/ingress).
|
||||
- `GET`/`POST /{uid}/edit` (`ContainerEditForm` -> `api.update_instance_config`, edits `run_as_uid`/`boot_language`/`boot_script`/`boot_command`/`restart_policy`/`start_on_boot`/`cpu_limit`/`mem_limit`).
|
||||
- Stacked literal lifecycle routes `POST /{uid}/{start,stop,restart,pause,resume}` (the no-wildcard rule below).
|
||||
- `POST /{uid}/sync` (bidirectional).
|
||||
- `POST /{uid}/delete` (soft, owner-or-admin via the admin guard).
|
||||
- Two JSON search endpoints `GET /{projects,users}/search` (back the create/edit search-selects via `db.query LIKE`).
|
||||
|
||||
**Route order:** `/{uid}/edit` and the literal verb routes are declared BEFORE the `/{uid}` catch-all.
|
||||
|
||||
Frontend: `static/js/ContainerList.js` (poll-render rows with inline action buttons + the create modal with two debounced search-select widgets and a boot-language toggle) and `static/js/ContainerEdit.js` (the edit page). Both route through `Http.send`/`Http.getJson` and surface errors via `app.toast`; the Terminal button reuses `app.containerTerminals.open(slug, uid, name)`. The instance detail page (`containers_instance.html`) has a config summary (boot language, run-as uid, start-on-boot) plus a **Status history** card rendering the server-side `instance_events`.
|
||||
|
||||
Devii: `container_create_instance` takes the boot/run-as/start_on_boot args; `container_configure_instance` (`controller._configure_instance` -> `api.update_instance_config`) edits the same fields; both admin-only, audited via `_DEVII_CONTAINER_EVENTS`.
|
||||
|
||||
## No wildcard verb route (load-bearing)
|
||||
|
||||
`routers/projects/containers/instances.py` registers every instance verb as a **literal** route - `start`/`stop`/`pause`/`resume`/`restart` (stacked `@router.post` decorators on one `instance_action` handler that reads the verb from `request.url.path`), plus `delete`/`exec`/`sync`/`schedules`. There is deliberately NO `POST /instances/{uid}/{action}` catch-all: a wildcard segment matches its literal siblings too (Starlette matches first-declared), so a catch-all would silently swallow `exec`/`sync`/`delete`/`schedules` as `{"error": "unknown action: exec"}` whenever it sat above them. Add any new instance verb as a literal route (and to the `instance_action` decorator stack if it just flips desired state) - never reintroduce a `{action}` wildcard.
|
||||
|
||||
## Host port allocation
|
||||
|
||||
Host ports are auto-allocated and globally unique. `parse_ports` accepts a bare container port (`8899`, host side `0` = auto) or a pinned `host:container`. `api.assign_host_ports` (called in `create_instance`) resolves every `host=0` to the lowest free port in `[HOST_PORT_MIN=20001, 65535]` that is neither in `used_host_ports()` (the union of every instance's published host ports from `ports_json`) nor currently bound on the host (`_host_port_free` test-binds `0.0.0.0:port`). A pinned host port already published by another instance is rejected up front. This guarantees no two instances ever collide on a host port - previously a silent `docker run` "port is already allocated" failure. The resolved host port is what gets stored in `ports_json` and what the ingress proxy reads.
|
||||
|
||||
## Floating terminals (interactive shell)
|
||||
|
||||
An instance's shell is NOT inline - it is a floating `<container-terminal>` (`static/js/components/ContainerTerminal.js`) over the exec WS (`/projects/{slug}/containers/instances/{uid}/exec/ws`, `pty.openpty()` + `docker exec -it`, admin + `service_manager.owns_lock()` gated). xterm.js + the fit addon are vendored under `static/vendor/xterm/` and lazy-loaded. Opened by the instance-page **Open terminal** button (`app.containerTerminals.open(slug, uid, name)`, `ContainerTerminalManager`) or by telling Devii to "attach <container>" - the admin-only `open_terminal` **client** action (`client_actions.py`, `requires_admin=True`) -> `DeviiClient._openTerminal` -> the same manager.
|
||||
|
||||
**`FloatingWindow` base.** It extends the generic `FloatingWindow` base (`static/js/components/FloatingWindow.js` + `static/css/floating-window.css`), which owns the Devii-style chrome: drag, native `resize:both`, maximize/fullscreen, geometry persistence. `app.windows` (`WindowManager`, `components/WindowManager.js`) assigns z-index on `pointerdown` so the last-clicked window is on top (below the avatar/toasts).
|
||||
|
||||
**The Devii terminal also extends `FloatingWindow`.** `devii/devii-terminal.js` reuses the base drag/geometry/preset/resize/persist plus `_window`/`_registerWindow` (z-order + context-menu) machinery, overriding only the parts that differ - `_setState` (Devii adds a `closed` state + launcher FAB), `_defaultGeometry` (bottom-right, 760x600), `_changeFont` (per-user `--devii-font-size` CSS var), `_contextItems`, and `_persistGeometry` (delegates to its richer `{state,geometry,focused}` blob under `devii-terminal-state`). Devii keeps native `resize:both` (no `.fw-resize` grip); `devii.css` is unchanged - the only base change required was a `get _dragClass()` getter (default `fw-dragging`) that Devii overrides to `devii-dragging`, so the base drag handlers toggle Devii's own class and its existing CSS drives everything.
|
||||
|
||||
**Resize protocol.** The exec WS treats a brace-leading JSON frame `{"type":"resize","cols","rows"}` as a control message: it `TIOCSWINSZ`-es the host pty (`fcntl.ioctl`) **and** signals the `docker exec` client (`proc.send_signal(SIGWINCH)`) so the new size forwards through to the container's exec tty (vim/top reflow). The SIGWINCH is required: with `start_new_session=True` the host slave is not the client's controlling terminal, so the kernel does not auto-deliver it; the pair shares its winsize, so the client reads the new size off its slave stdio and calls Docker's exec-resize API. Any non-resize frame is raw stdin (backward compatible). The fit addon drives it client-side, and the window has a dedicated bottom-right resize grip (`.fw-resize`, since xterm covers the native CSS resize corner - the base `FloatingWindow` adds a manual grip instead of relying on `resize:both`). xterm renders ANSI natively, so the old `cleanTerm` stripping is gone for the interactive shell (the one-shot exec box still strips output, since `docker exec` without `-t` is non-TTY).
|
||||
|
||||
**Persistence (tmux).** A `?session=<name>` query param (validated `^[A-Za-z0-9_-]{1,64}$`) makes the WS run the shell inside tmux (`tmux new-session -A -s <name>`, falling back to bash if tmux is absent), so the session survives WS disconnect/window close - `proc.kill` on disconnect kills the tmux *client*, the server daemon + session live on. The frontend keeps exactly **one persistent slot** at a time (`ContainerTerminalManager`, the last-opened terminal): it attaches to session `devplace`, **auto-reconnects** with capped backoff on unexpected WS close (not on code `1008`), and is remembered per-user in `localStorage` (`ct-persistent:<scope>`); `app.containerTerminals.restore()` (called once in `Application.js` after `window.app`) reopens it on the next page load. Opening another terminal demotes/calls `setPersistent(false)` on the previous one (its tmux session lingers in the container, but the frontend stops auto-reconnecting/remembering it); explicit window-close (`_onClose`) detaches + forgets (session still survives, reopen re-attaches).
|
||||
|
||||
**Right-click context menu.** Every window wires `app.contextMenu.attach(this.win, () => this._contextItems())` (right-click + long-press), opening the shared `dp-context-menu`. The base `FloatingWindow._contextItems` is Minimize/Normalize/Close; `ContainerTerminal` overrides it with Copy (xterm `getSelection`, disabled when `!term.hasSelection()`) / Paste (clipboard -> WS stdin) / Sync files / Restart / Terminate (`dp-dialog` confirm, then delete + close) / Minimize / Normalize / Project (-> `/projects/{slug}`) / Files (-> `/projects/{slug}/files`) / Close - the lifecycle items POST to the existing `instances/{uid}/{sync,restart,delete}` routes. Devii's `_contextItems` override returns Copy (live `window.getSelection()`, or the current input line when empty) / Paste (clipboard inserted into `.devii-input` at the caret, `selectionStart/End` splice, then refocus) / Minimize / Normalize / Close (it is not bound to a container or project). Copy/Paste both use the async Clipboard API with a hidden-`textarea` + `execCommand("copy")` write fallback.
|
||||
|
||||
**Minimize/Normalize.** Geometry presets (`_presetGeometry(w,h)`, smallest-usable / comfortable), exposed both as titlebar buttons (`data-win="minimize|normalize"`) and menu items on every window.
|
||||
|
||||
## Pravda image (load-bearing, workspace ownership)
|
||||
|
||||
The `ppy` image (`ppy.Dockerfile`, built by `make ppy`, context `devplacepy/services/containers/files`) is a `python:3.13-slim-bookworm` base with Playwright plus a broad set of common Python libraries preinstalled, plus CLI tools (`tmux`, `apache2-utils` for `ab`, `procps`/`htop`/`iftop`/`iotop`, `netcat-openbsd` for `nc`, `zip`/`unzip`, `fakeroot`, git/curl/wget/vim/ack).
|
||||
|
||||
The security hotpatch that used to run per build is now baked into `ppy.Dockerfile` once. The final stage:
|
||||
- `COPY`s the **sudo superclone** (`files/sudo`) over `/usr/local/bin/sudo` (+ symlink `/usr/bin/sudo`; the real `sudo` package is not installed).
|
||||
- `COPY`s the **`aptroot` fakeroot wrapper** (`files/aptroot`, symlinked over `apt`/`apt-get`/`dpkg` in `/usr/local/bin` so pravda installs system packages without root).
|
||||
- `COPY`s **`pagent`** (`files/pagent`, the stdlib AI agent; reads `DEVPLACE_OPENAI_URL`+`DEVPLACE_API_KEY`, falling back to its public endpoint + `DEEPSEEK_API_KEY`) to `/usr/bin/pagent.py`, plus `files/.vimrc` to `/home/pravda/.vimrc` (whose AI helper - `AiEditSelection` - targets the same gateway as pagent via `DEVPLACE_OPENAI_URL`/`DEVPLACE_API_KEY`, with a public fallback, never `api.openai.com`).
|
||||
- Evicts any pre-existing uid-1000 user, creates user **`pravda` at `1000:1000`**.
|
||||
- Hands pravda ownership of the toolchain AND the OS package trees (`chown -R pravda` over `/usr/local/lib`, `/usr/local/bin`, `/usr/lib/python3`, `/opt`, `/app`, `/home/pravda`, plus `/usr/lib`, `/usr/bin`, `/usr/sbin`, `/usr/share`, `/usr/include`, `/etc`, `/var/lib`, `/var/cache`, `/var/log`, `/srv` so `apt`/`dpkg` can write; `~/.local/bin` on `PATH`).
|
||||
- Ends on `USER pravda`.
|
||||
|
||||
**Why uid 1000.** `/app` is bind-mounted from the host, and under DooD a container's UID maps 1:1 to the host. A container running as **root** wrote root-owned files into the workspace; the app process (host `retoor`, uid 1000) then hit `[Errno 13] Permission denied` in `export_to_dir` on the next instance create - a permanent per-project brick. Pinning the container UID to `1000` makes every write land as `retoor`, and pravda owning the global site-packages means runtime `pip install` succeeds as pravda with no elevation.
|
||||
|
||||
**The sudo superclone (`files/sudo`, POSIX `sh`)** is sudo-CLI-compatible but **never swaps user**: it parses the full flag interface (`-u/-g/-E/-H/-n/-S/-i/-s/--`, leading `VAR=value` env assignments, `-V/-l/-v/-k/-K` short-circuits), exports `SUDO_USER`/`SUDO_UID`/`SUDO_GID`/`SUDO_COMMAND`, then `exec`s the command **as the current user (pravda)**. So even a blind `sudo apt ...` / `sudo -u root ...` runs as uid 1000 and **cannot create a root-owned file** - the residual risk is gone by construction.
|
||||
|
||||
**Rootless apt (`files/aptroot`).** Pravda installs system packages directly (`apt install <pkg>`, no sudo). `aptroot` is symlinked over `apt`/`apt-get`/`dpkg` in `/usr/local/bin` (ahead of `/usr/bin` on `PATH`) and execs the real tool under **`fakeroot`** with `APT::Sandbox::User=root`; dpkg's chown-to-root calls are virtualized while every file actually written lands owned by **pravda (uid 1000)**. Combined with pravda owning the system trees (`/usr`, `/etc`, `/var/lib`, `/var/cache`, `/var/log`, `/srv`, ...), `apt install <pkg>` works with no real euid 0 and still cannot brick the bind mount. Run `apt update` first (the image clears `/var/lib/apt/lists`); a package whose maintainer script needs genuinely privileged syscalls may still fail - bake those into `ppy.Dockerfile` (its `RUN apt-get` runs as root before `USER pravda`), then `make ppy`.
|
||||
|
||||
**Trade-off (intentional).** The only genuinely-root operation that still does NOT escalate is binding a port < 1024 - use a high port + `/p/<slug>` ingress instead. Enforcement lives entirely in the Dockerfile (no `--user` on `docker run`). The `export_to_dir` unlink-before-write fix remains as belt-and-suspenders (the app owns the workspace dir, so it may delete any stale file in it regardless of owner before rewriting it).
|
||||
|
||||
## `PRAVDA_*` runtime env injection
|
||||
|
||||
`api.run_spec_for` merges `api.pravda_env(instance)` over the instance's own `env_json` (PRAVDA keys win), so every running container gets these platform vars:
|
||||
- `DEVPLACE_BASE_URL` - the `site_url` setting via `seo.public_base_url()`.
|
||||
- `DEVPLACE_OPENAI_URL` - `{base}/openai/v1`, the OpenAI-compatible gateway base.
|
||||
- `DEVPLACE_API_KEY` - the **creating** user's (or `run_as_uid`'s) `api_key`, resolved live.
|
||||
- `DEVPLACE_USER_UID` - the project **owner**'s uid.
|
||||
- `DEVPLACE_CONTAINER_NAME` - the instance name.
|
||||
- `DEVPLACE_CONTAINER_UID` - the instance uid.
|
||||
- `DEVPLACE_INGRESS_URL` - the instance's absolute public ingress URL `{base}/p/{ingress_slug}` when published and `site_url` is set, relative `/p/{slug}` if `site_url` is unset, empty if the instance has no `ingress_slug`.
|
||||
|
||||
These let code inside a container call back into the platform and the AI gateway authenticated as the user. **Injected at run** (`docker run -e`), never baked into the image: the image is shared by every instance, but name/uid/api-key are per-instance and base-url/key must stay fresh, so they are resolved each launch and never stored as secrets in the DB.
|
||||
|
||||
Resolution needs two instance columns set at `create_instance`: `created_by` (= `actor[1]` when the actor is a user) feeds `DEVPLACE_API_KEY`, and `owner_uid` (= `project["user_uid"]`) feeds `DEVPLACE_USER_UID`. Instances created before this change have neither and degrade to an empty key/uid (base-url and container name/uid still resolve) until recreated. `DEVPLACE_BASE_URL`/`DEVPLACE_OPENAI_URL` are empty when `site_url` is unset, so set the admin `site_url` for container-to-platform calls (and ingress URLs) to work.
|
||||
|
||||
## Ingress (`/p/<slug>`)
|
||||
|
||||
`routers/proxy.py` (registered at `/p`) implements the ingress reverse proxy. An instance opts in with `ingress_slug` + `ingress_port` (must be one of its mapped container ports; slug unique, validated in `api.validate_ingress`). `_resolve(slug)` -> `store.find_instance_by_ingress` -> `api.proxy_target(instance)` returns `(gateway, host_port)` (the instance's recorded docker bridge gateway + published host port); the route reverse-proxies HTTP (httpx) and WebSocket (`websockets` client) to `http://{host}:{port}/{path}`, stripping the `/p/<slug>` prefix. The reconciler records `container_ip`/`container_gateway` from `docker inspect` each running tick.
|
||||
|
||||
**`proxy_target` dials the container's docker bridge gateway + the published host port** (`gateway:host_port`), NOT loopback and NOT the container's own bridge IP. This is deliberate: `127.0.0.1` fails where docker's `nat OUTPUT` excludes `127.0.0.0/8` from DNAT and no `docker-proxy` binds loopback for a `0.0.0.0`-published port; the container's own bridge IP can be dropped by docker's bridge-isolation rule (`DOCKER ! -i docker0 -o docker0 -j DROP`); the gateway+published-port path survives both. `DEVPLACE_CONTAINER_PROXY_HOST` overrides the host (e.g. `host.docker.internal` for a containerized app) and still uses the published host port; before a gateway is recorded the proxy falls back to `127.0.0.1`.
|
||||
|
||||
Ingress is **public** (no auth) but SSRF-safe: host/port are derived from the instance row, never from user input. The Devii container tools return an absolute `ingress_url` built from `seo.public_base_url()` (the admin `site_url` setting), so the agent fetches the public production URL, not localhost (which web tools refuse); unset `site_url` yields a relative `/p/<slug>`. nginx needs a `/p/` location with WS upgrade + long timeouts (present in `nginx/nginx.conf.template`).
|
||||
|
||||
## Production wiring
|
||||
|
||||
The container manager drives the host docker daemon, which needs heavy wiring - all carried by the opt-in `docker-compose.containers.yml` overlay (docker socket mount, `INSTALL_DOCKER_CLI=true` build arg, `group_add` the docker gid). **`make docker-build`/`make docker-up` always apply this overlay** and self-derive its inputs: `DOCKER_GID` via `stat -c '%g' /var/run/docker.sock` (the socket's owning group) and `DEVPLACE_DATA_DIR` = `$(CURDIR)/data` (the project's own dir at its real host absolute path). This makes the manager work out of the box with no `sudo`, no `/srv`, no manual `.env` edits. A plain `docker compose up -d` drops the overlay (no CLI, no socket) and silently re-breaks it - always update through the make targets.
|
||||
|
||||
**The DooD bind-mount gotcha:** `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must be mounted at an identical host+container path (the make targets use `$(CURDIR)/data` on both sides; manual `docker compose` users get a `/srv/devplace-data` default). Build contexts ship via the docker API tarball, so the container temp dir is fine. Set `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` only when a containerized app cannot route to the recorded gateway. See README "Container Manager wiring".
|
||||
|
||||
## Bidirectional newer-wins sync (load-bearing direction rule)
|
||||
|
||||
Sync is NOT one-directional import. `project_files.sync_dir_bidirectional(project_uid, workspace, user) -> {"exported", "imported"}` is the one helper that reconciles a project's virtual FS against an instance's `workspace_dir`: per file, the side with the newer timestamp wins (project `updated_at` epoch, via `datetime.fromisoformat(...).timestamp()`, vs filesystem `st_mtime`, with a 1s skew tolerance favouring export on ties), and a file present on only one side propagates to the other. It **NEVER deletes a file** - only creates/overwrites the older side. A **read-only** project (`is_readonly`) exports only, never imports (the read-only guard direction).
|
||||
|
||||
Both `api.sync_workspace` (HTTP/Devii sync action, returns `{exported, imported}`) and `api.sync_bidirectional_sync` (reconciler, non-blocking `record_event` system actor, logs only when non-zero) call the same helper. The reconciler runs it before every `_launch` AND on a ~60s wall-clock cadence over running instances (`SYNC_EVERY_SECONDS`, gated on `time.monotonic()` independent of the 5s reconcile tick). The per-instance boot-helper files (`.devplace_boot.py`/`.devplace_boot.sh`) are in `SYNC_SKIP_NAMES` so they never round-trip into the project.
|
||||
|
||||
## Run-as user = identity + API key ONLY (load-bearing constraint)
|
||||
|
||||
An instance's `run_as_uid` column selects WHICH DevPlace user's identity and `api_key` are injected (`DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`), resolved in `api.pravda_env` ahead of the `created_by`/`owner_uid` fallback chain. It does **NOT** change the container OS user, which is ALWAYS `pravda` (uid 1000) - required for the bind-mounted `/app` (DooD uid maps 1:1 to host). Validate it against an existing user via `api.validate_run_as`.
|
||||
|
||||
## Boot source precedence (load-bearing)
|
||||
|
||||
Columns `boot_language` (`none`|`python`|`bash`) + `boot_script` (multiline source) sit alongside the legacy `boot_command`. Precedence in `api.run_spec_for`: `boot_script` (by language) > `boot_command` > image CMD (`sleep infinity`). When a boot script is set, the reconciler writes it into the workspace (`api.materialize_boot_script`, `.devplace_boot.py`/`.devplace_boot.sh`, excluded from sync) before launch and runs `python|bash /app/.devplace_boot.<ext>`. `api.validate_boot` enforces the language set and a 100k char cap.
|
||||
|
||||
## start_on_boot is per-container
|
||||
|
||||
The `start_on_boot` integer flag (0/1) forces `desired_state=running` ONLY for flagged instances when `ContainerService` first runs (`_boot_pass`, one-time); all others keep their last `desired_state`.
|
||||
|
||||
## Status-change choke point
|
||||
|
||||
Every reconciler status mutation flows through `service._set_status(inst, changes, reason=)`, which diffs old vs new `status` and, on change, writes an `instance_events` `status_change` row AND an audit `container.instance.status` `record_system` event (`old_value`/`new_value`). `_reconcile`, `_launch`, and `_handle_exit` (terminal + policy_restart) all flow through it, so previously-silent transitions (`running->stopped`/`->paused`/exit) are now logged and visible on the admin detail page's **Status history** card. New code that changes an instance's status in the reconciler must use `_set_status`, never a raw `store.update_instance(... status ...)`.
|
||||
|
||||
## New columns are ensured in init_db and defaulted in store.create_instance
|
||||
|
||||
`run_as_uid`/`boot_language`/`boot_script`/`start_on_boot` are added to the `instances` ensure-block in `init_db` (filtered/queried columns must exist on the cached schema) and seeded in the `store.create_instance` base dict so pre-existing rows degrade gracefully.
|
||||
|
||||
## Testing without Docker
|
||||
|
||||
Use `FakeBackend` (its `image_exists` returns `True`) + `runtime.set_backend`, and monkeypatch `config.CONTAINER_WORKSPACES_DIR` to a tmp dir. See `tests/unit/services/containers.py` and `tests/api/containers.py` (argv, instance creation on `config.CONTAINER_IMAGE`, image-not-built guard, reconcile matrices, schedule firing, ingress validation + live HTTP proxy, HTTP admin gate).
|
||||
|
||||
## Vibe coding on-ramp (user-facing doc)
|
||||
|
||||
The container runtime is also the basis of "vibe coding": the public prose page `templates/docs/getting-started-vibing.html` (slug `getting-started-vibing`, `SECTION_GENERAL`, not admin-gated so the docs stay public while the feature is admin-only/Alpha) is the canonical user-facing guide. It teaches the Devii-driven flow project -> container -> start -> terminal (`create_project`, `container_create_instance`, `container_instance_action`, the `open_terminal` client action), documents the three baked-in agents (`dpc` = DevPlace Code at `/usr/bin/dpc`, the Claude-Code-class coding agent; `botje.py` = the copy of `services/containers/files/bot.py` at `/usr/bin/botje.py`; `pagent`), all metered through the container's own `DEVPLACE_API_KEY`, the full `PRAVDA_*` env table (see `api.pravda_env`), and ingress at `/p/<slug>` via `ingress_slug`/`ingress_port`.
|
||||
|
||||
When the runtime, the agent binaries, or the `PRAVDA_*`/ingress contract change, update this page alongside the source.
|
||||
|
||||
**The agents are gateway-only:** `dpc`/`d.py` and `botje.py`/`bot.py` use a single `molodetz` backend pointed at `DEVPLACE_OPENAI_URL` (the gateway); the former direct `api.deepseek.com` fallback backend was removed so every in-container AI call is ledgered under the run-as user and nothing bypasses `gateway_usage_ledger`. `pagent`/`.vimrc` already posted to the gateway URL (using `DEEPSEEK_API_KEY` only as a key fallback, never the DeepSeek endpoint). Rebuild the image (`make ppy`) for the change to reach running containers.
|
||||
@@ -415,13 +415,13 @@ def pravda_env(instance: dict) -> dict:
|
||||
slug = instance.get("ingress_slug") or ""
|
||||
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
||||
return {
|
||||
"DEVPLACE_BASE_URL": base_url,
|
||||
"DEVPLACE_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||
"DEVPLACE_API_KEY": api_key,
|
||||
"DEVPLACE_USER_UID": instance.get("run_as_uid") or user_uid,
|
||||
"DEVPLACE_CONTAINER_NAME": instance.get("name") or "",
|
||||
"DEVPLACE_CONTAINER_UID": instance.get("uid") or "",
|
||||
"DEVPLACE_INGRESS_URL": ingress_url,
|
||||
"PRAVDA_BASE_URL": base_url,
|
||||
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||
"PRAVDA_API_KEY": api_key,
|
||||
"PRAVDA_USER_UID": instance.get("run_as_uid") or user_uid,
|
||||
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
|
||||
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
|
||||
"PRAVDA_INGRESS_URL": ingress_url,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,637 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy import config, project_files, stealth
|
||||
from devplacepy.services.containers import store
|
||||
from devplacepy.services.containers.backend.base import (
|
||||
WORKSPACE_MOUNT,
|
||||
Mount,
|
||||
PortMapping,
|
||||
RunSpec,
|
||||
)
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.devii.tasks.schedule import (
|
||||
Schedule,
|
||||
now_utc,
|
||||
to_iso,
|
||||
)
|
||||
|
||||
IMAGE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
|
||||
INGRESS_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
MEM_RE = re.compile(r"^\d+(\.\d+)?[bkmgBKMG]?$")
|
||||
CPU_RE = re.compile(r"^\d+(\.\d+)?$")
|
||||
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
INSTANCE_LABEL = "devplace.instance"
|
||||
PROJECT_LABEL = "devplace.project"
|
||||
HOST_PORT_MIN = 20001
|
||||
HOST_PORT_MAX = 65535
|
||||
BOOT_LANGUAGES = ("none", "python", "bash")
|
||||
BOOT_SCRIPT_FILES = {"python": ".devplace_boot.py", "bash": ".devplace_boot.sh"}
|
||||
BOOT_SCRIPT_RUNNERS = {"python": "python", "bash": "bash"}
|
||||
MAX_BOOT_SCRIPT_CHARS = 100_000
|
||||
|
||||
|
||||
class ContainerError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_limits(cpu_limit: str, mem_limit: str) -> None:
|
||||
if cpu_limit and not CPU_RE.match(str(cpu_limit)):
|
||||
raise ContainerError("cpu limit must be a number, e.g. 1 or 1.5")
|
||||
if mem_limit and not MEM_RE.match(str(mem_limit)):
|
||||
raise ContainerError("memory limit must look like 512m, 1g, or a byte count")
|
||||
|
||||
|
||||
def validate_run_as(run_as_uid) -> str:
|
||||
uid = str(run_as_uid or "").strip()
|
||||
if not uid:
|
||||
return ""
|
||||
from devplacepy import database
|
||||
|
||||
user = database.get_users_by_uids([uid]).get(uid)
|
||||
if not user:
|
||||
raise ContainerError(f"run-as user not found: {uid}")
|
||||
return uid
|
||||
|
||||
|
||||
def validate_boot(boot_language, boot_script) -> tuple:
|
||||
language = str(boot_language or "none").strip().lower() or "none"
|
||||
if language not in BOOT_LANGUAGES:
|
||||
raise ContainerError(
|
||||
f"boot language must be one of {', '.join(BOOT_LANGUAGES)}"
|
||||
)
|
||||
script = str(boot_script or "")
|
||||
if language == "none":
|
||||
script = ""
|
||||
if len(script) > MAX_BOOT_SCRIPT_CHARS:
|
||||
raise ContainerError(
|
||||
f"boot script exceeds the {MAX_BOOT_SCRIPT_CHARS}-character limit"
|
||||
)
|
||||
if language != "none" and not script.strip():
|
||||
raise ContainerError("boot script is required when a boot language is set")
|
||||
return language, script
|
||||
|
||||
|
||||
def parse_ports(value) -> list:
|
||||
ports = []
|
||||
if not value:
|
||||
return ports
|
||||
items = (
|
||||
value if isinstance(value, list) else str(value).replace(",", "\n").splitlines()
|
||||
)
|
||||
for item in items:
|
||||
item = str(item).strip()
|
||||
if not item:
|
||||
continue
|
||||
proto = "tcp"
|
||||
if "/" in item:
|
||||
item, proto = item.split("/", 1)
|
||||
if ":" in item:
|
||||
host, container = item.split(":", 1)
|
||||
else:
|
||||
host, container = "0", item
|
||||
if not host.isdigit() or not container.isdigit():
|
||||
raise ContainerError(
|
||||
f"port '{item}' must be numeric host:container or a bare container port"
|
||||
)
|
||||
ports.append(PortMapping(int(host), int(container), proto.strip() or "tcp"))
|
||||
return ports
|
||||
|
||||
|
||||
def used_host_ports() -> set:
|
||||
ports = set()
|
||||
for instance in store.all_instances():
|
||||
for mapping in json.loads(instance.get("ports_json") or "[]"):
|
||||
host = int(mapping.get("host") or 0)
|
||||
if host:
|
||||
ports.add(host)
|
||||
return ports
|
||||
|
||||
|
||||
def _host_port_free(port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind(("0.0.0.0", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def allocate_host_port(reserved: set) -> int:
|
||||
for port in range(HOST_PORT_MIN, HOST_PORT_MAX + 1):
|
||||
if port in reserved:
|
||||
continue
|
||||
if _host_port_free(port):
|
||||
return port
|
||||
raise ContainerError(
|
||||
f"no free host port available in range {HOST_PORT_MIN}-{HOST_PORT_MAX}"
|
||||
)
|
||||
|
||||
|
||||
def assign_host_ports(port_list: list) -> list:
|
||||
reserved = used_host_ports()
|
||||
assigned = []
|
||||
for mapping in port_list:
|
||||
host = mapping.host
|
||||
if not host:
|
||||
host = allocate_host_port(reserved)
|
||||
elif host in reserved:
|
||||
raise ContainerError(
|
||||
f"host port {host} is already published by another instance"
|
||||
)
|
||||
reserved.add(host)
|
||||
assigned.append(PortMapping(host, mapping.container, mapping.proto))
|
||||
return assigned
|
||||
|
||||
|
||||
def parse_env(value) -> dict:
|
||||
env = {}
|
||||
if not value:
|
||||
return env
|
||||
if isinstance(value, dict):
|
||||
items = value.items()
|
||||
else:
|
||||
items = (line.split("=", 1) for line in str(value).splitlines() if "=" in line)
|
||||
for key, val in items:
|
||||
key = str(key).strip()
|
||||
if not ENV_KEY_RE.match(key):
|
||||
raise ContainerError(f"invalid environment variable name: {key}")
|
||||
env[key] = str(val)
|
||||
return env
|
||||
|
||||
|
||||
# ---------------- instances ----------------
|
||||
|
||||
|
||||
def validate_ingress(slug: str, port, port_list) -> tuple:
|
||||
slug = (slug or "").strip().lower()
|
||||
if not slug:
|
||||
return "", 0
|
||||
if not INGRESS_SLUG_RE.match(slug):
|
||||
raise ContainerError(
|
||||
"ingress slug must be lowercase letters, digits, or '-' (max 63 chars)"
|
||||
)
|
||||
for other in store.all_instances():
|
||||
if other.get("ingress_slug") == slug:
|
||||
raise ContainerError(f"ingress slug '{slug}' is already in use")
|
||||
ingress_port = int(port) if port else 0
|
||||
container_ports = {p.container for p in port_list}
|
||||
if ingress_port and ingress_port not in container_ports:
|
||||
raise ContainerError(
|
||||
f"ingress_port {ingress_port} must be one of the container ports you mapped"
|
||||
)
|
||||
if not ingress_port and len(container_ports) != 1:
|
||||
raise ContainerError(
|
||||
"set ingress_port to choose which mapped container port to publish"
|
||||
)
|
||||
return slug, ingress_port
|
||||
|
||||
|
||||
async def create_instance(
|
||||
project: dict,
|
||||
*,
|
||||
name: str,
|
||||
boot_command: str = "",
|
||||
boot_language: str = "none",
|
||||
boot_script: str = "",
|
||||
run_as_uid: str = "",
|
||||
start_on_boot: bool = False,
|
||||
env="",
|
||||
cpu_limit: str = "",
|
||||
mem_limit: str = "",
|
||||
ports="",
|
||||
volumes="",
|
||||
restart_policy: str = "never",
|
||||
autostart: bool = True,
|
||||
ingress_slug: str = "",
|
||||
ingress_port=None,
|
||||
actor=("system", "system"),
|
||||
) -> dict:
|
||||
if not await get_backend().image_exists(config.CONTAINER_IMAGE):
|
||||
raise ContainerError(
|
||||
f"the '{config.CONTAINER_IMAGE}' image is not built - run 'make ppy'"
|
||||
)
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(
|
||||
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
||||
)
|
||||
_validate_limits(cpu_limit, mem_limit)
|
||||
run_as_uid = validate_run_as(run_as_uid)
|
||||
boot_language, boot_script = validate_boot(boot_language, boot_script)
|
||||
port_list = assign_host_ports(parse_ports(ports))
|
||||
env_map = parse_env(env)
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ContainerError("instance name is required")
|
||||
ingress_slug, ingress_port = validate_ingress(ingress_slug, ingress_port, port_list)
|
||||
|
||||
workspace = Path(config.CONTAINER_WORKSPACES_DIR) / project["uid"]
|
||||
await asyncio.to_thread(
|
||||
project_files.export_to_dir, project["uid"], "", str(workspace)
|
||||
)
|
||||
|
||||
row = {
|
||||
"project_uid": project["uid"],
|
||||
"created_by": actor[1] if actor and actor[0] == "user" else "",
|
||||
"owner_uid": project.get("user_uid", ""),
|
||||
"run_as_uid": run_as_uid,
|
||||
"name": name,
|
||||
"boot_command": boot_command or "",
|
||||
"boot_language": boot_language,
|
||||
"boot_script": boot_script,
|
||||
"start_on_boot": 1 if start_on_boot else 0,
|
||||
"env_json": json.dumps(env_map),
|
||||
"cpu_limit": str(cpu_limit or ""),
|
||||
"mem_limit": str(mem_limit or ""),
|
||||
"ports_json": json.dumps(
|
||||
[
|
||||
{"host": p.host, "container": p.container, "proto": p.proto}
|
||||
for p in port_list
|
||||
]
|
||||
),
|
||||
"volumes_json": volumes
|
||||
if isinstance(volumes, str)
|
||||
else json.dumps(volumes or []),
|
||||
"restart_policy": restart_policy,
|
||||
"ingress_slug": ingress_slug,
|
||||
"ingress_port": ingress_port,
|
||||
"desired_state": store.DESIRED_RUNNING if autostart else store.DESIRED_STOPPED,
|
||||
"status": store.ST_CREATED,
|
||||
"workspace_dir": str(workspace),
|
||||
}
|
||||
instance = store.create_instance(row)
|
||||
store.record_event(
|
||||
instance, "created", actor[0], actor[1], {"image": config.CONTAINER_IMAGE}
|
||||
)
|
||||
if actor and actor[0] == "user":
|
||||
from devplacepy.utils import track_action
|
||||
|
||||
track_action(actor[1], "container")
|
||||
return instance
|
||||
|
||||
|
||||
def set_desired_state(
|
||||
instance: dict, desired: str, *, actor=("system", "system")
|
||||
) -> dict:
|
||||
if desired not in (
|
||||
store.DESIRED_RUNNING,
|
||||
store.DESIRED_STOPPED,
|
||||
store.DESIRED_PAUSED,
|
||||
):
|
||||
raise ContainerError("desired state must be running, stopped, or paused")
|
||||
store.update_instance(instance["uid"], {"desired_state": desired})
|
||||
store.record_event(instance, f"desire_{desired}", actor[0], actor[1])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def request_restart(instance: dict, *, actor=("system", "system")) -> dict:
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{"desired_state": store.DESIRED_RUNNING, "status": store.ST_RESTARTING},
|
||||
)
|
||||
store.record_event(instance, "restart", actor[0], actor[1])
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def mark_for_removal(instance: dict, *, actor=("system", "system")) -> None:
|
||||
store.update_instance(
|
||||
instance["uid"],
|
||||
{"desired_state": store.DESIRED_STOPPED, "status": store.ST_REMOVING},
|
||||
)
|
||||
store.record_event(instance, "remove", actor[0], actor[1])
|
||||
|
||||
|
||||
def update_instance_config(
|
||||
instance: dict,
|
||||
*,
|
||||
run_as_uid=None,
|
||||
boot_language=None,
|
||||
boot_script=None,
|
||||
boot_command=None,
|
||||
restart_policy=None,
|
||||
start_on_boot=None,
|
||||
cpu_limit=None,
|
||||
mem_limit=None,
|
||||
actor=("system", "system"),
|
||||
) -> dict:
|
||||
changes: dict = {}
|
||||
if run_as_uid is not None:
|
||||
changes["run_as_uid"] = validate_run_as(run_as_uid)
|
||||
if boot_language is not None or boot_script is not None:
|
||||
language = (
|
||||
boot_language
|
||||
if boot_language is not None
|
||||
else instance.get("boot_language", "none")
|
||||
)
|
||||
script = (
|
||||
boot_script if boot_script is not None else instance.get("boot_script", "")
|
||||
)
|
||||
language, script = validate_boot(language, script)
|
||||
changes["boot_language"] = language
|
||||
changes["boot_script"] = script
|
||||
if boot_command is not None:
|
||||
changes["boot_command"] = str(boot_command or "")[:500]
|
||||
if restart_policy is not None:
|
||||
if restart_policy not in store.RESTART_POLICIES:
|
||||
raise ContainerError(
|
||||
f"restart policy must be one of {', '.join(store.RESTART_POLICIES)}"
|
||||
)
|
||||
changes["restart_policy"] = restart_policy
|
||||
if start_on_boot is not None:
|
||||
changes["start_on_boot"] = 1 if start_on_boot else 0
|
||||
if cpu_limit is not None or mem_limit is not None:
|
||||
cpu = cpu_limit if cpu_limit is not None else instance.get("cpu_limit", "")
|
||||
mem = mem_limit if mem_limit is not None else instance.get("mem_limit", "")
|
||||
_validate_limits(cpu, mem)
|
||||
changes["cpu_limit"] = str(cpu or "")
|
||||
changes["mem_limit"] = str(mem or "")
|
||||
if not changes:
|
||||
return store.get_instance(instance["uid"])
|
||||
store.update_instance(instance["uid"], changes)
|
||||
store.record_event(
|
||||
instance, "configure", actor[0], actor[1], {"fields": sorted(changes)}
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def set_start_on_boot(
|
||||
instance: dict, enabled: bool, *, actor=("system", "system")
|
||||
) -> dict:
|
||||
store.update_instance(instance["uid"], {"start_on_boot": 1 if enabled else 0})
|
||||
store.record_event(
|
||||
instance, "start_on_boot", actor[0], actor[1], {"enabled": bool(enabled)}
|
||||
)
|
||||
return store.get_instance(instance["uid"])
|
||||
|
||||
|
||||
def materialize_boot_script(instance: dict) -> None:
|
||||
language = (instance.get("boot_language") or "none").strip().lower()
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
return
|
||||
for filename in BOOT_SCRIPT_FILES.values():
|
||||
stale = Path(workspace) / filename
|
||||
if stale.is_file():
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if language not in BOOT_SCRIPT_FILES:
|
||||
return
|
||||
script = instance.get("boot_script") or ""
|
||||
if not script.strip():
|
||||
return
|
||||
target = Path(workspace) / BOOT_SCRIPT_FILES[language]
|
||||
try:
|
||||
Path(workspace).mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(script, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def pravda_env(instance: dict) -> dict:
|
||||
from devplacepy import database, seo
|
||||
|
||||
base_url = seo.public_base_url()
|
||||
api_key = ""
|
||||
user_uid = instance.get("owner_uid") or ""
|
||||
for uid in (
|
||||
instance.get("run_as_uid"),
|
||||
instance.get("created_by"),
|
||||
instance.get("owner_uid"),
|
||||
):
|
||||
if not uid:
|
||||
continue
|
||||
user = database.get_users_by_uids([uid]).get(uid)
|
||||
if user and user.get("api_key"):
|
||||
api_key = user["api_key"]
|
||||
break
|
||||
slug = instance.get("ingress_slug") or ""
|
||||
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
|
||||
return {
|
||||
"PRAVDA_BASE_URL": base_url,
|
||||
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
|
||||
"PRAVDA_API_KEY": api_key,
|
||||
"PRAVDA_USER_UID": instance.get("run_as_uid") or user_uid,
|
||||
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
|
||||
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
|
||||
"PRAVDA_INGRESS_URL": ingress_url,
|
||||
}
|
||||
|
||||
|
||||
def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
|
||||
env = {**json.loads(instance.get("env_json") or "{}"), **pravda_env(instance)}
|
||||
ports = [
|
||||
PortMapping(p["host"], p["container"], p.get("proto", "tcp"))
|
||||
for p in json.loads(instance.get("ports_json") or "[]")
|
||||
]
|
||||
mounts = [Mount(instance["workspace_dir"], WORKSPACE_MOUNT, "rw")]
|
||||
for extra in json.loads(instance.get("volumes_json") or "[]"):
|
||||
if isinstance(extra, dict) and extra.get("host") and extra.get("container"):
|
||||
mounts.append(
|
||||
Mount(extra["host"], extra["container"], extra.get("mode", "rw"))
|
||||
)
|
||||
language = (instance.get("boot_language") or "none").strip().lower()
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
if language in BOOT_SCRIPT_FILES and (instance.get("boot_script") or "").strip():
|
||||
script_path = f"{WORKSPACE_MOUNT}/{BOOT_SCRIPT_FILES[language]}"
|
||||
command = [BOOT_SCRIPT_RUNNERS[language], script_path]
|
||||
elif boot:
|
||||
command = ["/bin/sh", "-c", boot]
|
||||
else:
|
||||
command = ["sleep", "infinity"]
|
||||
return RunSpec(
|
||||
image=image_tag,
|
||||
name=instance["slug"],
|
||||
labels={
|
||||
INSTANCE_LABEL: instance["uid"],
|
||||
PROJECT_LABEL: instance["project_uid"],
|
||||
},
|
||||
env=env,
|
||||
cpu_limit=instance.get("cpu_limit", ""),
|
||||
mem_limit=instance.get("mem_limit", ""),
|
||||
ports=ports,
|
||||
mounts=mounts,
|
||||
restart_policy=instance.get("restart_policy", "never"),
|
||||
command=command,
|
||||
)
|
||||
|
||||
|
||||
async def sync_workspace(instance: dict, user: dict) -> dict:
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
raise ContainerError("instance has no workspace")
|
||||
counts = await asyncio.to_thread(
|
||||
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user
|
||||
)
|
||||
store.record_event(
|
||||
instance,
|
||||
"sync",
|
||||
"user",
|
||||
user["uid"],
|
||||
{"exported": counts["exported"], "imported": counts["imported"]},
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
|
||||
workspace = instance.get("workspace_dir")
|
||||
if not workspace:
|
||||
return {"exported": 0, "imported": 0}
|
||||
counts = project_files.sync_dir_bidirectional(
|
||||
instance["project_uid"], workspace, user
|
||||
)
|
||||
if counts["exported"] or counts["imported"]:
|
||||
store.record_event(
|
||||
instance,
|
||||
"sync",
|
||||
"service",
|
||||
"system",
|
||||
{"exported": counts["exported"], "imported": counts["imported"]},
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
|
||||
if action not in ("start", "stop"):
|
||||
raise ContainerError("schedule action must be start or stop")
|
||||
first = schedule.first_run(now_utc())
|
||||
return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
|
||||
|
||||
|
||||
# ---------------- aggregation ----------------
|
||||
|
||||
|
||||
def _percentile(values: list, pct: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
index = min(len(ordered) - 1, int(round((pct / 100.0) * (len(ordered) - 1))))
|
||||
return float(ordered[index])
|
||||
|
||||
|
||||
def instance_stats(instance_uid: str) -> dict:
|
||||
metrics = store.recent_metrics(instance_uid, limit=720)
|
||||
cpu = [m.get("cpu_pct", 0) for m in metrics]
|
||||
mem = [m.get("mem_bytes", 0) for m in metrics]
|
||||
return {
|
||||
"samples": len(metrics),
|
||||
"cpu_avg": round(sum(cpu) / len(cpu), 2) if cpu else 0.0,
|
||||
"cpu_p95": round(_percentile(cpu, 95), 2),
|
||||
"mem_max": max(mem) if mem else 0,
|
||||
"mem_avg": int(sum(mem) / len(mem)) if mem else 0,
|
||||
}
|
||||
|
||||
|
||||
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
|
||||
try:
|
||||
with stealth.stealth_sync_client(timeout=timeout) as client:
|
||||
response = client.get(f"http://{host}:{port}/")
|
||||
return f"HTTP {response.status_code}"
|
||||
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
|
||||
return f"unreachable: {type(exc).__name__}"
|
||||
|
||||
|
||||
def _net_entry(data: dict) -> dict:
|
||||
net = (data or {}).get("NetworkSettings") or {}
|
||||
if net.get("IPAddress") or net.get("Gateway"):
|
||||
return net
|
||||
for entry in (net.get("Networks") or {}).values():
|
||||
if entry and (entry.get("IPAddress") or entry.get("Gateway")):
|
||||
return entry
|
||||
return {}
|
||||
|
||||
|
||||
def container_ip_from_inspect(data: dict) -> str:
|
||||
return (_net_entry(data).get("IPAddress") or "").strip()
|
||||
|
||||
|
||||
def container_gateway_from_inspect(data: dict) -> str:
|
||||
return (_net_entry(data).get("Gateway") or "").strip()
|
||||
|
||||
|
||||
def _ingress_container_port(instance: dict, port_maps: list) -> int:
|
||||
ingress_port = int(instance.get("ingress_port") or 0)
|
||||
if ingress_port:
|
||||
for mapping in port_maps:
|
||||
if int(mapping.get("container") or 0) == ingress_port:
|
||||
return ingress_port
|
||||
return 0
|
||||
return int(port_maps[0].get("container") or 0) if port_maps else 0
|
||||
|
||||
|
||||
def _host_port_for(port_maps: list, container_port: int) -> int:
|
||||
for mapping in port_maps:
|
||||
if int(mapping.get("container") or 0) == container_port:
|
||||
return int(mapping.get("host") or 0)
|
||||
return 0
|
||||
|
||||
|
||||
def proxy_target(instance: dict) -> tuple:
|
||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||
container_port = _ingress_container_port(instance, port_maps)
|
||||
if not container_port:
|
||||
return None, None
|
||||
host_port = _host_port_for(port_maps, container_port)
|
||||
if config.CONTAINER_PROXY_HOST:
|
||||
return (config.CONTAINER_PROXY_HOST, host_port) if host_port else (None, None)
|
||||
if not host_port:
|
||||
return None, None
|
||||
gateway = (instance.get("container_gateway") or "").strip()
|
||||
return (gateway or "127.0.0.1", host_port)
|
||||
|
||||
|
||||
def instance_runtime(instance: dict) -> dict:
|
||||
boot = (instance.get("boot_command") or "").strip()
|
||||
port_maps = json.loads(instance.get("ports_json") or "[]")
|
||||
container_ip = (instance.get("container_ip") or "").strip()
|
||||
container_gateway = (instance.get("container_gateway") or "").strip()
|
||||
target_host, target_port = proxy_target(instance)
|
||||
probe_host = config.CONTAINER_PROXY_HOST or container_gateway or "127.0.0.1"
|
||||
ports = []
|
||||
for mapping in port_maps:
|
||||
host_port = int(mapping.get("host") or 0)
|
||||
ports.append(
|
||||
{
|
||||
"container": int(mapping.get("container") or 0),
|
||||
"host": host_port,
|
||||
"proto": mapping.get("proto", "tcp"),
|
||||
"reachable": _port_reachable(probe_host, host_port)
|
||||
if host_port
|
||||
else False,
|
||||
}
|
||||
)
|
||||
ingress_serving = (
|
||||
_http_probe(target_host, target_port)
|
||||
if target_host and target_port
|
||||
else "no ingress port mapped"
|
||||
)
|
||||
return {
|
||||
"command": boot or "image CMD (no boot_command set)",
|
||||
"ports": ports,
|
||||
"container_ip": container_ip,
|
||||
"container_gateway": container_gateway,
|
||||
"ingress_target": (
|
||||
f"{target_host}:{target_port}" if target_host and target_port else ""
|
||||
),
|
||||
"ingress_port": int(instance.get("ingress_port") or 0),
|
||||
"ingress_serving": ingress_serving,
|
||||
"status_ok_for_ingress": instance.get("status") == store.ST_RUNNING,
|
||||
"restart_count": int(instance.get("restart_count") or 0),
|
||||
"exit_code": instance.get("exit_code"),
|
||||
"container_id": (instance.get("container_id") or "")[:12],
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
" retoor <retoor@molodetz.nl>
|
||||
" Self-contained config for the ppy container. No external plugins or managers:
|
||||
" it works out of the box with the stock vim, and the AI edit feature uses only
|
||||
" curl and the DEVPLACE_* gateway env that every instance is launched with.
|
||||
" curl and the PRAVDA_* gateway env that every instance is launched with.
|
||||
|
||||
set nocompatible
|
||||
filetype plugin indent on
|
||||
@@ -70,7 +70,7 @@ function! s:GetVisualSelection() abort
|
||||
endfunction
|
||||
|
||||
function! s:GatewayUrl() abort
|
||||
let l:base = substitute($DEVPLACE_OPENAI_URL, '/\+$', '', '')
|
||||
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
|
||||
if empty(l:base)
|
||||
return 'https://openai.app.molodetz.nl/v1/chat/completions'
|
||||
elseif l:base =~# '/chat/completions$'
|
||||
@@ -92,7 +92,7 @@ function! AiEditSelection() abort
|
||||
endif
|
||||
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
|
||||
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
|
||||
let l:api_key = !empty($DEVPLACE_API_KEY) ? $DEVPLACE_API_KEY : $DEEPSEEK_API_KEY
|
||||
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
|
||||
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
|
||||
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
|
||||
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
" retoor <retoor@molodetz.nl>
|
||||
" Self-contained config for the ppy container. No external plugins or managers:
|
||||
" it works out of the box with the stock vim, and the AI edit feature uses only
|
||||
" curl and the PRAVDA_* gateway env that every instance is launched with.
|
||||
|
||||
set nocompatible
|
||||
filetype plugin indent on
|
||||
syntax on
|
||||
|
||||
set encoding=utf-8
|
||||
set fileencoding=utf-8
|
||||
set termencoding=utf-8
|
||||
set mouse=a
|
||||
set backspace=indent,eol,start
|
||||
set autoindent
|
||||
set smartindent
|
||||
set tabstop=4
|
||||
set shiftwidth=4
|
||||
set expandtab
|
||||
set number
|
||||
set showmatch
|
||||
set showtabline=2
|
||||
set laststatus=2
|
||||
set hidden
|
||||
set incsearch
|
||||
set hlsearch
|
||||
set wildmenu
|
||||
set ttimeoutlen=50
|
||||
|
||||
if has('clipboard')
|
||||
set clipboard=unnamedplus
|
||||
endif
|
||||
|
||||
if !isdirectory(expand('~/.vim/undo'))
|
||||
call mkdir(expand('~/.vim/undo'), 'p')
|
||||
endif
|
||||
set undofile
|
||||
set undodir=~/.vim/undo
|
||||
|
||||
set statusline=%f\ %h%m%r\ %=\ [%{&filetype}]\ [%l,%c]\ %p%%
|
||||
highlight StatusLine cterm=bold ctermfg=15 ctermbg=24
|
||||
highlight StatusLineNC cterm=none ctermfg=250 ctermbg=236
|
||||
highlight ErrorMsg cterm=bold ctermfg=red ctermbg=none
|
||||
|
||||
let mapleader = ","
|
||||
|
||||
inoremap <C-n> <ESC>:tabnext<CR>
|
||||
inoremap <C-p> <ESC>:tabprevious<CR>
|
||||
nnoremap <C-n> :tabnext<CR>
|
||||
nnoremap <C-p> :tabprevious<CR>
|
||||
nnoremap <Tab> :tabnext<CR>
|
||||
nnoremap <C-Tab> :tabprevious<CR>
|
||||
nnoremap <C-e> :tabnew<Space>
|
||||
inoremap <C-e> <ESC>:tabnew<Space>
|
||||
|
||||
if has('autocmd')
|
||||
autocmd BufReadPost * if line("'\"") > 0 && line("'\"") <= line("$") | exe "normal! g'\"" | endif
|
||||
endif
|
||||
|
||||
function! s:GetVisualSelection() abort
|
||||
let [l:line_start, l:col_start] = [line("'<"), col("'<")]
|
||||
let [l:line_end, l:col_end] = [line("'>"), col("'>")]
|
||||
let l:lines = getline(l:line_start, l:line_end)
|
||||
if empty(l:lines)
|
||||
return ''
|
||||
endif
|
||||
let l:lines[-1] = l:lines[-1][: l:col_end - (l:line_start == l:line_end ? 1 : 2)]
|
||||
let l:lines[0] = l:lines[0][l:col_start - 1 :]
|
||||
return join(l:lines, "\n")
|
||||
endfunction
|
||||
|
||||
function! s:GatewayUrl() abort
|
||||
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
|
||||
if empty(l:base)
|
||||
return 'https://openai.app.molodetz.nl/v1/chat/completions'
|
||||
elseif l:base =~# '/chat/completions$'
|
||||
return l:base
|
||||
endif
|
||||
return l:base . '/chat/completions'
|
||||
endfunction
|
||||
|
||||
function! AiEditSelection() abort
|
||||
let l:instruction = input('AI instruction: ')
|
||||
if empty(l:instruction)
|
||||
echo 'Cancelled.'
|
||||
return
|
||||
endif
|
||||
let l:orig = s:GetVisualSelection()
|
||||
if empty(l:orig)
|
||||
echo 'No selection.'
|
||||
return
|
||||
endif
|
||||
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
|
||||
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
|
||||
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
|
||||
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
|
||||
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
|
||||
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
|
||||
\ . ' -H ' . shellescape('Content-Type: application/json')
|
||||
\ . ' -d ' . shellescape(l:json)
|
||||
let l:reply = system(l:cmd)
|
||||
if v:shell_error
|
||||
echohl ErrorMsg | echom 'AI request failed' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = matchstr(l:reply, '"content":\s*"\zs\(.\{-}\)\ze"\s*[,}]')
|
||||
if empty(l:text)
|
||||
echohl ErrorMsg | echom 'No content in AI response' | echohl None
|
||||
return
|
||||
endif
|
||||
let l:text = substitute(l:text, '\\n', "\n", 'g')
|
||||
let l:text = substitute(l:text, '\\"', '"', 'g')
|
||||
let l:text = substitute(l:text, '\\t', "\t", 'g')
|
||||
normal! gv
|
||||
normal! c
|
||||
call feedkeys(l:text, 'n')
|
||||
endfunction
|
||||
|
||||
xnoremap <silent> <Leader>a :<C-u>call AiEditSelection()<CR>
|
||||
@@ -49,14 +49,14 @@ from urllib.parse import quote, unquote, urlencode, urlparse, urlsplit, urlunspl
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _resolve_devplace_url() -> str:
|
||||
base = os.environ.get("DEVPLACE_BASE_URL", "").strip().rstrip("/")
|
||||
base = os.environ.get("PRAVDA_BASE_URL", "").strip().rstrip("/")
|
||||
if base:
|
||||
return base
|
||||
return os.environ.get("DEVPLACE_URL", "https://devplace.net").strip().rstrip("/")
|
||||
|
||||
|
||||
def _resolve_llm_endpoint() -> str:
|
||||
base = os.environ.get("DEVPLACE_OPENAI_URL", "").strip().rstrip("/")
|
||||
base = os.environ.get("PRAVDA_OPENAI_URL", "").strip().rstrip("/")
|
||||
if not base:
|
||||
base = "https://openai.app.molodetz.nl/v1"
|
||||
return base if base.endswith("/chat/completions") else base + "/chat/completions"
|
||||
@@ -64,7 +64,7 @@ def _resolve_llm_endpoint() -> str:
|
||||
|
||||
DEVPLACE_URL = _resolve_devplace_url()
|
||||
DEVPLACE_API_KEY = (
|
||||
os.environ.get("DEVPLACE_API_KEY")
|
||||
os.environ.get("PRAVDA_API_KEY")
|
||||
or os.environ.get("DEVPLACE_API_KEY")
|
||||
or "019ea58c-fae0-7112-8025-e629a54104a4"
|
||||
)
|
||||
@@ -81,7 +81,7 @@ LLM_ENDPOINT = _resolve_llm_endpoint()
|
||||
LLM_BASE_URL = LLM_ENDPOINT.rsplit("/chat/completions", 1)[0]
|
||||
MODEL = "molodetz"
|
||||
LLM_API_KEY = str(
|
||||
os.environ.get("DEVPLACE_API_KEY")
|
||||
os.environ.get("PRAVDA_API_KEY")
|
||||
or os.environ.get("LLM_API_KEY")
|
||||
or ""
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -659,7 +659,7 @@ from typing import Any, Callable, Optional
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
def _resolve_llm_endpoint() -> str:
|
||||
base = os.environ.get("DEVPLACE_OPENAI_URL", "").strip().rstrip("/")
|
||||
base = os.environ.get("PRAVDA_OPENAI_URL", "").strip().rstrip("/")
|
||||
if not base:
|
||||
base = "https://openai.app.molodetz.nl/v1"
|
||||
return base if base.endswith("/chat/completions") else base + "/chat/completions"
|
||||
@@ -669,7 +669,7 @@ LLM_ENDPOINT = _resolve_llm_endpoint()
|
||||
LLM_BASE_URL = LLM_ENDPOINT.rsplit("/chat/completions", 1)[0]
|
||||
MODEL = "molodetz"
|
||||
API_KEY = (
|
||||
os.environ.get("DEVPLACE_API_KEY")
|
||||
os.environ.get("PRAVDA_API_KEY")
|
||||
or os.environ.get("LLM_API_KEY")
|
||||
or str(uuid.uuid4())
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -41,14 +41,14 @@ from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode, urljoin, urlparse
|
||||
|
||||
def _resolve_api_url() -> str:
|
||||
base = os.environ.get("DEVPLACE_OPENAI_URL", "").strip().rstrip("/")
|
||||
base = os.environ.get("PRAVDA_OPENAI_URL", "").strip().rstrip("/")
|
||||
if not base:
|
||||
return "https://openai.app.molodetz.nl/v1/chat/completions"
|
||||
return base if base.endswith("/chat/completions") else base + "/chat/completions"
|
||||
|
||||
|
||||
API_URL = _resolve_api_url()
|
||||
API_KEY = os.environ.get("DEVPLACE_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or ""
|
||||
API_KEY = os.environ.get("PRAVDA_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or ""
|
||||
RSEARCH_BASE_URL = "https://rsearch.app.molodetz.nl"
|
||||
RSEARCH_MODES = ("search", "chat", "describe", "health")
|
||||
RSEARCH_MAX_BYTES = 8 * 1024 * 1024
|
||||
@@ -1644,7 +1644,7 @@ async def describe_image(prompt: str, image_path: str):
|
||||
|
||||
api_key = API_KEY
|
||||
if not api_key:
|
||||
return json.dumps({"status": "error", "error": "DEVPLACE_API_KEY or DEEPSEEK_API_KEY missing"})
|
||||
return json.dumps({"status": "error", "error": "PRAVDA_API_KEY or DEEPSEEK_API_KEY missing"})
|
||||
|
||||
payload = {
|
||||
"model": DEFAULT_MODEL,
|
||||
@@ -1811,7 +1811,7 @@ async def delegate(task: str, allowed_tools: Optional[list] = None):
|
||||
"""
|
||||
api_key = API_KEY
|
||||
if not api_key:
|
||||
return json.dumps({"status": "error", "error": "DEVPLACE_API_KEY or DEEPSEEK_API_KEY missing"})
|
||||
return json.dumps({"status": "error", "error": "PRAVDA_API_KEY or DEEPSEEK_API_KEY missing"})
|
||||
if allowed_tools:
|
||||
sub_payloads = [
|
||||
t for t in get_tool_payloads()
|
||||
@@ -2567,7 +2567,7 @@ async def amain(headed: bool = False, prompt: str = "", timeout: float = 0) -> N
|
||||
|
||||
api_key = API_KEY
|
||||
if not api_key:
|
||||
md.print("**Error:** `DEVPLACE_API_KEY` or `DEEPSEEK_API_KEY` missing.")
|
||||
md.print("**Error:** `PRAVDA_API_KEY` or `DEEPSEEK_API_KEY` missing.")
|
||||
sys.exit(1)
|
||||
|
||||
clone_mode = os.environ.get("MAK_CLONE_MODE") == "1"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
# Database API service (`devplacepy/services/dbapi/`, `devplacepy/routers/dbapi/`)
|
||||
|
||||
This file documents the primary-administrator-only, read-only generic database API. Claude Code auto-loads it when a file under `devplacepy/services/dbapi/` is read or edited.
|
||||
|
||||
## Overview
|
||||
|
||||
`dbapi/` (routers package, mounted at `/dbapi`) is a **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus.
|
||||
|
||||
It exposes per-table reads, a validated raw `query()`, a natural-language-to-SQL designer backed by the internal AI gateway, and async query execution streamed over a websocket. **It can never insert, update, replace, delete, or restore data in any way** - there are no write endpoints and no write tools (this is a hard rule; removed because the generic write surface bypassed every per-route admin safeguard - role-change seniority guards, container privilege locks, the audit trail). Reuses the existing dataset helpers, the `JobService`/`ProgressHub`/lock-owner websocket pattern, the AI gateway, and the Devii action catalog - it does not re-implement any of them.
|
||||
|
||||
## Auth (single boundary, primary-admin ONLY)
|
||||
|
||||
`services/dbapi/policy.py` `caller_for(request)` returns a `Caller` ONLY for the **primary administrator** (a session OR api_key whose user passes `utils.is_primary_admin` - the earliest-created Admin, the same identity that gates backup downloads, resolved by `database.get_primary_admin_uid`), else `None`. **There is no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (removed at the user's request); there is no service-to-service access. Every other administrator is refused exactly like a member, so the `Caller.kind` is always `"admin"`. `require_caller` raises `DbApiDenied`; the router's `require_dbapi_caller` turns that into a `403` and an audited `database.access.denied`. Members, guests, non-primary admins, and internal callers never reach a handler. **Note:** `services/pubsub/policy.py` no longer reuses this `caller_for` (it would have leaked the primary-admin restriction into the pub/sub bus); it resolves its own admin/internal actor so any admin stays `privileged` on the bus.
|
||||
|
||||
## Table guard
|
||||
|
||||
`policy.assert_table(name)` enforces a name regex, existence in `db.tables`, and a deny-list (`DEFAULT_DENY = {sessions, password_resets, cache_state}` plus the `dbapi_deny_tables` setting). Used on every table-scoped path and indirectly by the validator's table extraction, so neither a crafted segment nor an NL-designed query can touch a denied table.
|
||||
|
||||
## Reads only (`services/dbapi/crud.py`, `routers/dbapi/crud.py`)
|
||||
|
||||
`GET /dbapi/{table}` (keyset pagination newest-first, `?filter.<col>=`, `?gte/lte/gt/lt.<col>=`, `?search=`, `?before=`, `?limit=`, `?include_deleted=`) and `GET /dbapi/{table}/{key}/{value}`. There are **no insert/update/delete/restore routes** - the router exposes only the two read handlers, and `services/dbapi/crud.py` keeps only read functions (`schema`, `list_rows`, `count_rows`, `get_row`). The only audit events the API emits are `database.access.denied`, `database.query`, and `database.nl.design`.
|
||||
|
||||
## `query()` is hard SELECT-only (`services/dbapi/validate.py`)
|
||||
|
||||
`classify()` parses with `sqlglot` (statement type, table list, `has_where/has_join/has_limit`, suspicious notes for missing `WHERE/JOIN/LIMIT`, multiple statements, `ATTACH/PRAGMA/VACUUM`). `validate_select()` rejects anything but a single SELECT, then `dry_run()` does `EXPLAIN` on a **separate read-only** connection (`file:...?mode=ro` + `PRAGMA query_only=ON`) for true validity. `run_select()` executes read-only. `POST /dbapi/query` returns `409` for non-SELECT (the database API is read-only; data cannot be changed through it), `400` for invalid SQL, else rows + a `suspicious` list. The database API can never mutate data through any path.
|
||||
|
||||
## NL-to-SQL (`services/dbapi/nl2sql.py`)
|
||||
|
||||
`POST /dbapi/nl` builds a system prompt from the table schema + 5 example rows + a soft-delete rule (auto `deleted_at IS NULL` unless `apply_soft_delete=false`), calls the internal gateway (`INTERNAL_GATEWAY_URL`, model `dbapi_nl_model` or `molodetz`, auth = the primary-admin caller's api_key for attribution), and **reprompts with the validator's error until the SQL validates** (max 3 attempts). Returns the validated SQL by default; `execute=true` also runs it read-only and returns rows.
|
||||
|
||||
## Async (`services/dbapi/service.py` `DbApiJobService`, kind `dbquery`)
|
||||
|
||||
`POST /dbapi/query/async` validates then `queue.enqueue`s; the service re-validates (defense in depth), streams rows in batches to its own `ProgressHub`, writes the full result to `config.DBAPI_DIR/{uid}/result.json`, and returns stats. `GET /dbapi/query/{uid}` (status), `GET /dbapi/query/{uid}/result` (rows from disk, extends retention), `WS /dbapi/query/{uid}/ws` (lock-owner gated, `4013` retry, snapshot-then-stream - the SEO pattern). Config on `/admin/services`: `dbapi_max_rows`, `dbapi_nl_model`, `dbapi_nl_system_preamble`, `dbapi_deny_tables`, plus the standard Jobs fields.
|
||||
|
||||
## Devii (primary-admin gated)
|
||||
|
||||
**Read-only** tools `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query` (SELECT-only; surface `suspicious`), and `db_design_query` (NL->SQL), all flagged `requires_primary_admin=True` (alongside `requires_admin=True`) in `actions/catalog.py`. The `Action.requires_primary_admin` flag is filtered by `Catalog.tool_schemas_for(authenticated, is_admin, is_primary_admin)` and enforced again in `Dispatcher.dispatch`, so these tools are **added to the LLM tool list only for the primary administrator** - every other administrator's (and member's/guest's) Devii never receives the schemas and is unaware the database API exists. `is_primary_admin` is threaded WS/Telegram/CLI -> `hub.get_or_create(is_primary_admin=)` -> `DeviiSession` -> `Dispatcher`, mirroring how `is_admin` flows (`routers/devii.py` `_resolve_ws_owner`, `services/telegram/bridge.py`, `services/devii/cli.py` which runs both flags `True` as the trusted local operator, and the headless scheduler in `service.py`). There are **no write tools** - the former `db_insert_row`/`db_update_row`/`db_delete_row` were removed along with the HTTP write routes, so Devii cannot change data through the database API.
|
||||
|
||||
## nginx
|
||||
|
||||
`/dbapi/query/<uid>/ws` has its own upgrade location.
|
||||
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
CITATION_MARKER = re.compile(r"\[(\d+)\]")
|
||||
|
||||
CHAT_TOP_K = 10
|
||||
MAX_CONTEXT_CHARS = 16000
|
||||
CHAT_MAX_TOKENS = 1400
|
||||
CHAT_TOP_K = 8
|
||||
MAX_CONTEXT_CHARS = 9000
|
||||
CHAT_MAX_TOKENS = 900
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are the DeepSearch research assistant. Answer the user's question using ONLY "
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
CITATION = re.compile(r"\[\s*(\d+)\s*(?:-\s*(\d+)\s*)?\]")
|
||||
SKIP_BLOCK = re.compile(
|
||||
r"(<a\b[^>]*>.*?</a>|<code\b[^>]*>.*?</code>|<pre\b[^>]*>.*?</pre>)",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _linkify(text: str, source_count: int) -> str:
|
||||
def replace(match: re.Match) -> str:
|
||||
start = int(match.group(1))
|
||||
end = int(match.group(2)) if match.group(2) else start
|
||||
if end < start:
|
||||
return match.group(0)
|
||||
numbers = [n for n in range(start, end + 1) if 1 <= n <= source_count]
|
||||
if not numbers:
|
||||
return match.group(0)
|
||||
return "".join(
|
||||
f'<a class="ds-cite" href="#ds-source-{n}" data-cite="{n}">[{n}]</a>'
|
||||
for n in numbers
|
||||
)
|
||||
|
||||
return CITATION.sub(replace, text)
|
||||
|
||||
|
||||
def link_citations(html, source_count: int) -> Markup:
|
||||
if not html or source_count <= 0:
|
||||
return Markup(html or "")
|
||||
segments = SKIP_BLOCK.split(str(html))
|
||||
rendered = [
|
||||
segment if index % 2 == 1 else _linkify(segment, source_count)
|
||||
for index, segment in enumerate(segments)
|
||||
]
|
||||
return Markup("".join(rendered))
|
||||
@@ -18,6 +18,10 @@ def _sources(report: dict) -> list[dict]:
|
||||
return report.get("sources") or []
|
||||
|
||||
|
||||
def _gaps(report: dict) -> list[str]:
|
||||
return report.get("gaps") or []
|
||||
|
||||
|
||||
def to_markdown(report: dict) -> str:
|
||||
query = report.get("query", "")
|
||||
lines: list[str] = [f"# DeepSearch report: {query}", ""]
|
||||
@@ -33,14 +37,6 @@ def to_markdown(report: dict) -> str:
|
||||
generated = report.get("generated_at") or datetime.now(timezone.utc).isoformat()
|
||||
lines.append(f"- Generated: {generated}")
|
||||
lines.append("")
|
||||
if report.get("synthesis") == "heuristic":
|
||||
lines.extend(
|
||||
[
|
||||
"> Degraded report: automatic synthesis failed for this run, so the "
|
||||
"sections below show raw source material.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
summary = report.get("summary", "")
|
||||
if summary:
|
||||
lines.extend(["## Summary", "", summary, ""])
|
||||
@@ -61,6 +57,13 @@ def to_markdown(report: dict) -> str:
|
||||
lines.append("Sources: " + ", ".join(str(c) for c in citations))
|
||||
lines.append(f"\nConfidence: {confidence}")
|
||||
lines.append("")
|
||||
gaps = _gaps(report)
|
||||
if gaps:
|
||||
lines.append("## Open gaps")
|
||||
lines.append("")
|
||||
for gap in gaps:
|
||||
lines.append(f"- {gap}")
|
||||
lines.append("")
|
||||
sources = _sources(report)
|
||||
if sources:
|
||||
lines.append("## Sources")
|
||||
@@ -105,6 +108,12 @@ def _html_document(report: dict) -> str:
|
||||
parts.append(
|
||||
f"<p class='meta'>Confidence {finding.get('confidence', 0)}</p>"
|
||||
)
|
||||
gaps = _gaps(report)
|
||||
if gaps:
|
||||
parts.append("<h2>Open gaps</h2><ul>")
|
||||
for gap in gaps:
|
||||
parts.append(f"<li>{html.escape(gap)}</li>")
|
||||
parts.append("</ul>")
|
||||
sources = _sources(report)
|
||||
if sources:
|
||||
parts.append("<h2>Sources</h2><ol>")
|
||||
|
||||
@@ -7,7 +7,6 @@ import time
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,10 +41,7 @@ async def request_completion(
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"chat gateway returned {response.status_code}")
|
||||
data: dict = response.json()
|
||||
cost_info = parse_usage_headers(response.headers) or {}
|
||||
usage = data.get("usage") or {}
|
||||
usage["cost_usd"] = cost_info.get("cost_usd", 0.0)
|
||||
return data, usage, elapsed_ms
|
||||
return data, data.get("usage") or {}, elapsed_ms
|
||||
|
||||
|
||||
async def complete_chat(
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
This file documents the Devii assistant subsystem (devplacepy/services/devii/ and all its subdirectories: virtual_tools, behavior, email, telegram, container, client, customization, tasks, rsearch, agentic, actions). Claude Code loads it automatically whenever any file under devplacepy/services/devii/ is read or edited.
|
||||
|
||||
## Overview and account scope
|
||||
|
||||
`DeviiService` is the in-platform agentic assistant (a relocated standalone agent), exposed as a WebSocket terminal at `/devii/ws` (router `routers/devii.py`, prefix `/devii`) and reachable from the **Devii** user-dropdown item (`data-devii-open`) and auto-opened on `/docs` (`data-devii-autoopen`). Opt-in (`default_enabled=False`). Frontend is `static/js/DeviiTerminal.js` (an `Application.js`-instantiated controller, `app.devii`) plus the `devii-terminal`/`devii-avatar` custom elements and ported renderers under `static/js/devii/`, the vendored md-clippy avatar under `static/vendor/md-clippy/`, and `static/css/devii.css`.
|
||||
|
||||
A signed-in user's Devii drives **their own** account: the agent's `PlatformClient` uses this instance as base URL and the user's `api_key` as Bearer auth, so admins get admin-level tools. The **LLM** calls use the same `api_key` too: the hub threads `owner_kind` into `_build_settings` -> `build_settings(cfg, base_url, api_key, owner_kind)`, which sets `Settings.ai_key = api_key` for `owner_kind == "user"` (else the configured/internal gateway key). So a user's Devii spend is attributed to them at the gateway (`user:<uid>`) and counts toward any per-user gateway limit, while guests fall back to the internal key. Guests get an unauthenticated sandbox client. Every turn is audited in `devii_turns`.
|
||||
|
||||
## Sessions, hub, and channels
|
||||
|
||||
`DeviiHub` keeps one `DeviiSession` per owner-and-**channel** `(owner_kind, owner_id, channel)` - `user`/uid or `guest`/`devii_guest` cookie, channel `main` (default, the floating terminal) or `docs` (the in-page docs chat). A session holds a `set` of WebSockets and **broadcasts** every frame to all of that owner-channel's tabs/devices. After a turn it persists `agent._messages` to `devii_conversations` (users only; rehydrated on reconnect -> survives restarts), appends a `devii_usage_ledger` row, and a `devii_turns` audit row. On `attach` it sends a `{"type":"history"}` snapshot so a new tab renders the prior conversation. Tasks persist in `devii_tasks` (owner-scoped; guests use an in-memory db via `tasks.store.memory_db()`).
|
||||
|
||||
**Channels are an independent conversation thread only.** A `channel` separates the floating terminal (`main`) from the in-page docs chat (`docs`) so each has its own conversation thread for the same owner. The WS reads `?channel=` (`routers/devii.py`, allowlisted to `{"main","docs"}`, falls back to `main`) and threads it `get_or_create(..., channel=)` -> `DeviiSession(channel=)`. **Only** `devii_conversations` is keyed `(owner_kind, owner_id, channel)` (column added + NULL->`main` backfilled + index extended in `init_db`); lessons, behavior, virtual tools, tasks, and the `devii_usage_ledger`/`devii_turns` 24h quota stay owner-scoped (deliberately shared across channels). `find(...)`/`get_or_create(...)` default `channel="main"` so existing callers (e.g. `/devii/adopt`) are unchanged. The `docs` channel is driven only by `<dp-docs-chat>` (`static/js/components/AppDocsChat.js`): a light-DOM component that calls `/devii/session` (guest cookie), opens `DeviiSocket(handlers, "docs")`, renders user/agent bubbles in docs style (`static/css/docs-chat.css`) via the devii markdown renderer, stubs any `avatar`/`client` request so the agent never hangs, and seeds its first question from the `seed` attribute (the sidebar search box, intercepted). It is mounted on `/docs/search.html` (`templates/docs/_chat.html`); the docs pages no longer carry `data-devii-autoopen`. The docs page no longer auto-opens the floating terminal.
|
||||
|
||||
## The Docii docs channel
|
||||
|
||||
The `docs` channel is branded **Docii**, a documentation-only assistant, built by specialising the same `DeviiSession` on `self.channel == "docs"` (no separate service):
|
||||
|
||||
- **Prompt.** `session.py` sets `self._system_prompt = DOCS_SYSTEM_PROMPT` (instead of `_system_prompt_for`) - it forces the agent to call `search_docs` first for every question, to recursively refine-and-search (reading the returned section `content`, then searching again with new keywords) until grounded, to answer ONLY from retrieved docs, and to do no platform actions. `_compose_system_prompt()` returns it verbatim for docs (the owner `BEHAVIOR_HEADER` is NOT appended, so a user's general Devii behavior rules can't derail it).
|
||||
- **Tools.** `_builtin_tools()` filters `CATALOG.tool_schemas_for(...)` to the `DOCS_TOOLS` allowlist (`{"search_docs"}`); `_refresh_tools()` for docs sets `self.tools[:] = _builtin_tools()` with NO virtual tools. So the docs agent has exactly one tool and cannot wander.
|
||||
- **Greeting.** `bootstrap_greeting()` returns `DOCS_GREETING` for docs.
|
||||
- **search_docs.** `services/devii/docs/controller.py` delegates to the in-process page index `docs_search.search_pages(...)` (no HTTP), returning per result `title`, `url` (`/docs/{slug}.html`), `score` (BM25), and `content` (page text truncated to `CONTENT_CHARS`), admin-filtered by the dispatcher's `is_admin`. So "visiting the search results" is repeated `search_docs` calls; the default 40 `max_tool_iterations` leaves ample room to recurse.
|
||||
- **References and inline linking.** Because results carry real `url`s and `score`s, the prompt REQUIRES the answer to (a) link inline to documented pages with markdown links to their `url` and (b) end with a `## References` list of the pages used as clickable links with their score. The devii markdown renderer makes `/docs/...` links clickable client-side.
|
||||
- **Topic gate (follow-up vs new).** `_run_turn` runs `_docs_topic_gate(text)` before the main loop (docs channel only). When there is prior history it calls `_classify_topic` (a no-tools `LLMClient.complete_text` with `TOPIC_CLASSIFIER_PROMPT`, parsed by `_parse_topic`, defaulting to `follow_up` on any failure). On `new` it resets `agent._messages` to `[system]`, clears the persisted docs conversation, and emits `{type:"topic", decision, reason}`; the frontend clears the log, re-shows the current question, and prints a reasoning note. On `follow_up` it keeps context and shows the note.
|
||||
- **Isolation from Devii (critical).** The docs channel is built with an EPHEMERAL `owned_db` (`hub.get_or_create`: `db if owner_kind=="user" and channel=="main" else memory_db()`), so its task/lesson/behavior/virtual-tool stores are private and empty - a Docii reflection never pollutes the user's Devii memory and vice-versa. The **Scheduler only starts in the `main` channel** (`ensure_scheduler_started`: `if not self._started and self.channel == "main"`), so Devii's scheduled tasks never fire inside a docs session. (Regression fixed: previously the docs session's scheduler ran owner tasks over the shared `devii_tasks` table, and the search-only Docii agent tried to fulfil a `run_js` task by spamming `search_docs` - tasks are a `main`-only feature.)
|
||||
- The `main` channel is untouched (full 90-tool catalog + behavior header + Devii greeting).
|
||||
|
||||
## Docs search mode and BM25 fallback
|
||||
|
||||
The docs search surface is admin-configurable via the `docs_search_mode` site setting (`agent` | `bm25`, default `agent`, on `/admin/settings`, seeded in `init_db` `operational_defaults`, field in `AdminSettingsForm`). `routers/docs/views.py` `docs_page` (slug `search`) resolves the effective mode: `_agent_search_state(request, user, is_admin)` returns `disabled` (Devii off, or guests-disabled for a guest), `quota` (viewer over their `daily_limit_for`/`spent_24h`), or `ok`. Agent renders **only** when `mode=="agent"` and state `ok`; otherwise it runs the classic `docs_search.search(...)` (BM25, `docs_search.py`) and renders `templates/docs/_search.html`. `docs_base.html` branches the `kind=="search"` include on `search_mode`; on a quota fallback (`quota_fallback`) the BM25 page shows a "reached your daily AI assistant limit" hint. So `docs_search.py`/`_search.html` are live again (the bm25 path), not orphaned.
|
||||
|
||||
## Conversation, lesson, and task persistence
|
||||
|
||||
Conversation history persists to `devii_conversations` (rehydrated on reconnect, surviving restarts); per-turn cost goes to `devii_usage_ledger` (the authoritative 24h-spend source - the in-memory `CostTracker` is display-only) and an audit row to `devii_turns`; tasks live in `devii_tasks` (guests use an in-memory store).
|
||||
|
||||
**Per-owner self-learning memory is privacy-critical.** The `LessonStore` (reflect/recall) is **owner-scoped, never shared**: `LessonStore(db, owner_kind, owner_id)` filters every read/write by owner. The hub builds one per session over the same `owned_db` as the task store - the main `db` (table `devii_lessons`) for signed-in users (persistent, isolated, survives restarts) and a fresh `memory_db()` for guests (ephemeral, scoped to that web session). `forget_lessons` (agentic tool -> `LessonStore.clear()`/`delete()`) lets the user purge them; the system prompt forbids storing credentials/secrets in lessons. **Regression to avoid: do NOT share one `LessonStore` across sessions** - that leaked one user's reflected lessons (including credentials) into every other user's recall.
|
||||
|
||||
## Reminders and scheduled tasks (persistent across reboot)
|
||||
|
||||
`create_task` queues a self-contained prompt that a fresh agent runs later (`services/devii/tasks/`): `kind=once` (`delay_seconds` for relative, `run_at` UTC for absolute), `interval` (`every_seconds`), or `cron`. The per-session `Scheduler` ticks every 1s and executes due rows through the session's executor, so the result is broadcast to any connected tab and buffered (`type:"task"` frame) when the terminal is closed.
|
||||
|
||||
**The scheduler is no longer tied to a live WebSocket.** It is started by `session.ensure_scheduler_started()`, called both on `attach` AND by the lock-owner `DeviiService._ensure_task_schedulers()` each `run_once` (and promptly at boot): that pass scans the shared `devii_tasks` for every user owner with an enabled `pending`/`running` task (`tasks.store.pending_owner_ids(db)`), resolves the user (api_key/username/is_admin/timezone), and `hub.get_or_create(...)`s a headless session whose scheduler then runs the task - so a queued reminder survives a server reboot and fires even if the user never reopens Devii. `hub.gc_idle()` skips a session with `has_pending_tasks()` so a task-only session is not reaped between ticks.
|
||||
|
||||
A task created as a reminder carries `notify=1`: when it finishes, `session._deliver_reminder` sends a `utils.create_notification(owner, "reminder", result, target_url="/devii")` (the `reminder` notification type), so the in-app notification + live toast reach the user regardless of the terminal.
|
||||
|
||||
**Timezone awareness.** The terminal sends a `clientinfo` frame (`Intl...timeZone` + UTC offset) on connect; `session.set_clientinfo` stores it and persists `users.timezone` (`database.set_user_timezone`). `session._compose_system_prompt()` injects a `# CURRENT TIME` block (UTC now + the user's local time/timezone, falling back to the stored `users.timezone` for a headless run) so the agent converts wall-clock requests to a correct UTC `run_at`; relative requests use `delay_seconds`. The `REMINDERS AND SCHEDULED TASKS` system-prompt rule makes the agent SCHEDULE rather than fire a delayed instruction immediately, and set `notify=true` for reminders.
|
||||
|
||||
## Cost, quota, and the financial-data-is-admin-only rule
|
||||
|
||||
**Financial cap.** The rolling-24h `$` limit is read from `devii_usage_ledger` (`UsageLedger.spent_24h`), **not** the in-memory `CostTracker` (which resets on reboot and is display-only). Checked before each turn in the router; a turn already over is blocked (the over-limit WS error states only "Daily AI quota reached (100%)", never a dollar figure). The caps are `devii_user_daily_usd`, `devii_guest_daily_usd`, and `devii_admin_daily_usd` (default `0` = unlimited, admins exempt by default), resolved via `config.effective_daily_limit`. `config.effective_daily_limit(cfg, owner_kind, is_admin)` is the single source of truth, used by both `service.daily_limit_for` and `build_settings`: guests use `devii_guest_daily_usd` (default $0.05), users `devii_user_daily_usd` ($1.00), and **administrators are exempt by default** via `devii_admin_daily_usd` (default `0.0`, where **`0` = unlimited**). All three are editable on the Devii service config (`/admin/services`). The WS gate is `if limit > 0 and spent >= limit` so a `0` cap never blocks.
|
||||
|
||||
**Every billed turn is ledgered, not just interactive ones:** `session._record_spend` writes the usage delta in the `finally` of both `_run_turn` AND the scheduler executor, and it runs even when a turn is cancelled/superseded, so scheduled-task spend and interrupted-turn spend count against the cap rather than being forgiven. Pricing is admin-configurable - `cost/tracker.py` resolves a `Pricing` object per `CostTracker` rather than import-time env constants.
|
||||
|
||||
**Quotas are resettable** (clearing the owner's `devii_usage_ledger` rows): per-user via `POST /admin/users/{uid}/reset-ai-quota` (button on the admin Users page), globally via `POST /admin/ai-quota/reset-guests` and `/reset-all` (buttons on `/admin/ai-usage`), and from the CLI via `devplace devii reset-quota <username> | --guests | --all`.
|
||||
|
||||
**Financial data is admin-only:** any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the **percentage** of their 24h quota used. The owner's admin status is resolved server-side (`is_admin(user)`) and threaded WS -> `hub.get_or_create(is_admin=)` -> `DeviiSession(is_admin=)` -> `Dispatcher(is_admin=)`. The `Action` dataclass has a `requires_admin` flag (`cost_stats` is admin-only USD; the member-safe `usage_quota` tool returns **only** `{used_pct, turns_today, limit_reached}` with no money and is available to everyone). Gating is double: `Catalog.tool_schemas_for(authenticated, is_admin)` never hands an admin-only tool's schema to a non-admin, and the dispatcher independently raises `AuthRequiredError` for any `requires_admin` action a non-admin attempts. `usage_quota`'s data comes from `DeviiSession._quota_snapshot` (ledger `spent_24h`/`turns_24h` over `settings.daily_limit_usd`); for non-admin owners the system prompt also appends a hard rule forbidding any cost disclosure (defense in depth). `GET /devii/usage` mirrors this: it always returns `used_pct`/`turns_today` and adds `spent_24h`/`limit` only for admins. The standalone `devii` CLI runs with `is_admin=True` (the local operator owns the process). The catalog's cost/analytics HTTP tools `ai_usage` (GET `/admin/ai-usage/data`, USD breakdown) and `site_analytics` (GET `/admin/analytics`) are also `requires_admin=True`, so a non-admin session is never offered them and the dispatcher blocks them even if named - the platform 403 is no longer the only guard. The profile page is correctly split too (`_ai_quota(include_cost=viewer_is_admin)` emits dollars only for admins, in both its HTML and JSON forms, so a member fetching their own profile with `Accept: application/json` never sees dollars either).
|
||||
|
||||
## Aggregate analytics (no pagination)
|
||||
|
||||
Site analytics is a **documented, admin-secured HTTP endpoint** - `GET /admin/analytics` (`routers/admin/` package, returns JSON, `is_admin` check -> 403 otherwise) backed by `database.get_platform_analytics()` (single UNION query over `posts/comments/gists/projects.created_at`, TTL-cached 60s; total members, active users 24h/7d/30d, signed-in-now, signups, content totals, top authors). Devii calls it as a normal `http` catalog action `site_analytics` (GET `/admin/analytics`, `top_n` query param) - the platform enforces admin, so it behaves identically for the web and the `devii` CLI and never touches the DB directly from the agent. It is documented in the Admin API docs group (`docs_api.py`, id `admin-analytics`). The system prompt tells the model to use it for any "how many"/"how active" question instead of paging `admin_list_users` (which caused a pagination storm), and to `search_docs` before guessing a route rather than probing URLs.
|
||||
|
||||
## Context-window sizing (DeepSeek V4 Flash, 1M tokens)
|
||||
|
||||
The upstream model `deepseek-v4-flash` (what `deepseek-chat` routes to; default `gateway_model`) has a 1,048,576-token context and 384,000-token max output. All Devii size limits are **characters**, derived in `services/devii/config.py` from one source of truth: `CONTEXT_INPUT_BUDGET_TOKENS = CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS` (600,576 tokens), and `DEFAULT_CONTEXT_COMPACT_THRESHOLD = CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN` (3 chars/token = ~1.8M chars). At the conservative 3-chars/token estimate the worst-case input at compaction plus the full max output plus the system reserve sums to exactly the 1M window, so it can never overflow. `DEFAULT_MAX_RESPONSE_CHARS` (200,000) is the master chunk knob: every non-`chunks` tool result passes through `wrap_if_large(result, max_response_chars)` in `actions/dispatcher.py`, and `read_more` slices are clamped to it, so a file up to ~200KB returns in **one** read instead of paging in 12KB slices (the old read_more storm). `OUTPUT_CAP_CHARS` (`agentic/loop.py`, 400,000) must stay **above** `max_response_chars` or it would re-truncate a full chunk envelope. `ChunkStore` caches up to `STORE_MAX_CHARS` (8MB) per entry, `STORE_MAX_ENTRIES` (16) entries; `SUMMARY_INPUT_CAP` (`agentic/compaction.py`, 600,000) bounds what the summarizer ingests when compacting. When changing the upstream model, retune from `CONTEXT_WINDOW_TOKENS`/`MAX_OUTPUT_TOKENS` - everything else derives.
|
||||
|
||||
## Multi-worker service-lock routing
|
||||
|
||||
Hubs are per-process, so `/devii/ws` is served **only** by the worker that holds the background-service lock (`service_manager.owns_lock()`, set in `main.py` startup); a non-owner worker `close(4013)` (an application code in the private 4000-4999 range, reliably delivered as the browser `CloseEvent.code`). `DeviiSocket.js` recognises 4013 as "wrong worker, not yet settled" and fast-retries in ~200ms **silently** (no "disconnected, reconnecting..." notice), so with 2 prod workers the client converges on the owner in a fraction of a second instead of bouncing every 1500ms. The visible "connected." notice is emitted on the first server frame (`onReady`), never on raw socket open, so an accepted-then-4013-closed handshake on the wrong worker is invisible. The disabled/no-service path keeps the standard `1013` (a real, user-visible disconnect). The cap stays correct regardless because it reads the DB.
|
||||
|
||||
## Client-side browser-automation tool channel
|
||||
|
||||
Beyond the avatar, Devii has a `client`/browser channel using the same request/response pattern: `services/devii/client/ClientController` is bound on `attach`, and `actions/client_actions.py` exposes `get_page_context`, `run_js`, `highlight_element`, `clear_highlights`, `show_toast`, `scroll_to_element`, `navigate_to`, `reload_page` (`handler="client"`, all `requires_auth=False`). The session's generic `_browser_request(channel, action, args)` powers both `avatar` and `client`; the router resolves both `avatar_result` and `client_result`. Frontend `static/js/devii/DeviiClient.js` executes the actions in the user's own browser and the terminal routes `type:"client"` frames to it, returning `client_result`. This powers live on-screen tutorials, page-context awareness, and navigation/refresh. `navigate_to`/`reload_page` reply *before* unloading so the turn completes, then the persistent session reconnects.
|
||||
|
||||
**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.
|
||||
|
||||
## Shared browser session (auth adoption)
|
||||
|
||||
The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.
|
||||
|
||||
## Screen awareness
|
||||
|
||||
The `SYSTEM_PROMPT` instructs Devii, after a mutation, to `get_page_context` and `reload_page` when the page the user is viewing displays the data just changed, so settings/list/detail views the user is watching update live without a manual refresh.
|
||||
|
||||
## Project filesystem tools: line-range editing and the overwrite-protection guard
|
||||
|
||||
Devii's project filesystem tools include surgical **line-range editing** (`project_read_lines`, `project_replace_lines`, `project_insert_lines`, `project_delete_lines`, `project_append_file`) so it edits large text files without resending them, and `Dispatcher._run_http` enforces an **overwrite-protection guard**: `project_write_file` is rejected only when the target `(slug, path)` **already exists** (the guard probes `GET /projects/{slug}/files/raw`) and the agent has not read it this session (call `project_read_file` first); creating a new file needs no prior read. Reads are recorded in the per-session `Dispatcher._read_files` set. This steers the agent to update files rather than blindly overwrite them and is agent-scoped only (the human UI and public HTTP API are unaffected). The `WRITING AND EDITING FILES` system-prompt rule mirrors it.
|
||||
|
||||
## Web fetch and arbitrary HTTP (`handler="fetch"`)
|
||||
|
||||
`services/devii/fetch/FetchController` (a standalone `httpx.AsyncClient`, not `PlatformClient`) backs two tools: `fetch_url` (read a page as readable text, `requires_auth=False`) and `http_request` (`requires_auth=True`) - the general external HTTP client. `http_request` takes `method` (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), `headers` (object), and one body of `json` (object/array, Content-Type set automatically), `form` (url-encoded), or `body` (raw string), and returns `{http_status, headers, content_type, content}`; it does **not** raise on non-2xx so the model can read API errors. Both run through one `_stream()` (`fetch_url` via `_download` with `raise_5xx=True`; `http_request` with `raise_5xx=False`) sharing the SSRF `_guard` (refuses private/loopback unless `DEVII_FETCH_ALLOW_PRIVATE`), `fetch_max_bytes` cap, timeout, and decoding. The `object`-typed params required adding an `object` branch to `Action.tool_schema()` in `spec.py` (emits a valid open-object schema). The `SYSTEM_PROMPT` "WEB REQUESTS AND EXTERNAL APIS" rule tells the agent to call `http_request` DIRECTLY for an API and never wrap it in a self-invoking user-defined tool (a virtual tool only re-runs the agent and cannot do I/O, so wrapping the call recurses into the `MAX_EVAL_DEPTH` guard - the exact failure this fixed). To attach a remote file see `attach_url` / `store_attachment_from_url` under Attachments and media.
|
||||
|
||||
## Remote web tools (rsearch, non-platform)
|
||||
|
||||
`services/devii/rsearch/RsearchController` (`handler="rsearch"`, registered in `registry.py`, specs in `actions/rsearch_actions.py`, all `requires_auth=False`) exposes four tools that hit the EXTERNAL public service `https://rsearch.app.molodetz.nl` over a standalone `httpx.AsyncClient` (like `FetchController`, not the platform-bound `PlatformClient`): `rsearch` (web/image search, optional `content`/`deep`/`type=images`), `rsearch_answer` (AI answer grounded in fresh web search, returns answer + sources), `rsearch_chat` (direct AI chat, no search), `rsearch_describe_image` (vision describe a public image URL). These are NOT platform-specific: the `SYSTEM_PROMPT` "REMOTE WEB TOOLS" section makes the model prefer platform tools always and call `rsearch_*` only when the user explicitly asks to search the web/an outside source, never to answer questions about this instance. When adding another remote service, add a controller + handler literal in `spec.py` + a dispatcher branch rather than routing external calls through `PlatformClient` (which is hardwired to the instance base URL).
|
||||
|
||||
rsearch has its **own** read timeout (`rsearch_timeout_seconds`, field `devii_rsearch_timeout` / env `DEVII_RSEARCH_TIMEOUT`, default **300s** with a 30s connect cap via `httpx.Timeout`) - it does NOT share `fetch_timeout_seconds`, because web-grounded answers legitimately take minutes.
|
||||
|
||||
**rsearch is metered into the gateway ledger.** Because these calls never traverse the gateway upstream, `RsearchController` (constructed with `owner_kind`/`owner_id` from the dispatcher) records one `gateway_usage_ledger` row per call inside its single choke point `_request()` via `GatewayUsageLedger.record_external(...)` (backend `rsearch`, zero tokens, the flat admin-set `gateway_rsearch_cost_per_call`, success and failure both logged). The DeepSearch worker's `crawl.search_queries` emits a `{"type":"rsearch"}` NDJSON frame per search that `DeepsearchService._run_worker` ledgers in-process under the job owner. This keeps `gateway_usage_ledger` (and `/admin/ai-usage`) a complete record of platform AI spend - external AI calls included. `record_external` is the helper to reuse for any future off-gateway AI call.
|
||||
|
||||
## Email tools (IMAP/SMTP, non-platform)
|
||||
|
||||
A signed-in user's Devii can connect to their OWN external mailbox via `services/devii/email/EmailController` (`handler="email"`, specs in `actions/email_actions.py`). See `devplacepy/services/email/CLAUDE.md` for the full protocol engine, credential storage, and tool list.
|
||||
|
||||
## Telegram bot
|
||||
|
||||
Devii is reachable over Telegram via `channel="telegram"` sessions driven in-process by `services/telegram/bridge.py`. See `devplacepy/services/telegram/CLAUDE.md` for the worker/bridge architecture, pairing flow, and message-update behavior.
|
||||
|
||||
## Database API tools (`db_*`, primary-administrator only)
|
||||
|
||||
Devii exposes read-only `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query`, and `db_design_query` tools gated `requires_primary_admin=True`. See `devplacepy/services/dbapi/CLAUDE.md` for the full auth boundary, validation pipeline, and async query service - these tools are added to the LLM tool list only for the primary administrator; every other session is unaware they exist.
|
||||
|
||||
## All Devii/gateway network timeouts are admin-configurable with a five-minute (300s) floor
|
||||
|
||||
The Devii LLM-client and `PlatformClient` read timeout is `timeout_seconds` (field `devii_timeout`), the fetch/docs tool timeout is `fetch_timeout_seconds` (field `devii_fetch_timeout`), web search is `rsearch_timeout_seconds` (`devii_rsearch_timeout`); each defaults to 300s with `minimum=MIN_TIMEOUT_SECONDS` (300). The gateway upstream timeout is `gateway_timeout` (`minimum=TIMEOUT_MIN`=300), which also bounds the vision describe-image call (it shares the gateway's httpx client, no per-call override). A stored value below the floor fails `ConfigField.coerce()` and `read()` falls back to the default, so old sub-300 values upgrade automatically. Devii's LLM timeout was previously a hardcoded 45s while the gateway waited up to 180s x retries - large prompts aborted as `[model error] Could not reach the model endpoint:` (an httpx timeout, which stringifies to empty). `build_settings()` now reads these from config instead of hardcoding them.
|
||||
|
||||
## Config
|
||||
|
||||
Config is all `config_fields` (AI url/model/key, base url, plan/verify toggles, max iterations, JS-execution toggle, user/guest 24h caps, guests toggle, pricing). `effective_config()` falls back the AI key to `DEVII_AI_KEY` and the base url to the instance origin.
|
||||
|
||||
## Backend confidentiality
|
||||
|
||||
Devii must never disclose the underlying model, provider, or any upstream URL - enforced in two layers. The `SYSTEM_PROMPT` (`agent.py`) forbids it even when such values appear inside a tool result. Structurally, `text.redact_backend()` runs on every JSON HTTP tool response in `format_response()` and scrubs the values of `REDACT_FIELD_KEYS` (`gateway_upstream_url`/`gateway_model`/`gateway_vision_url`/`gateway_vision_model`) and any stat labelled in `REDACT_STAT_LABELS` (`Model`) to `[hidden]`, so the admin-services tools cannot leak the gateway's upstream even though the admin settings UI still shows the real values. Add a config key to `REDACT_FIELD_KEYS` if a new field would expose backend infrastructure to the agent.
|
||||
|
||||
## CLI
|
||||
|
||||
`pyproject.toml [project.scripts]` ships `devii = "devplacepy.services.devii.cli:main"`. No new dependency (`httpx`/`dataset` already required). The md-clippy avatar is vendored under `static/vendor/md-clippy/`; its `index.js` must not set `globalThis.app` (it would clobber the DevPlace `app`) and its AI proxy is `/devii/clippy/ai/chat`. The standalone `devii` CLI runs with `is_admin=True` (the local operator owns the process) and both `is_admin`/`is_primary_admin` `True` for the trusted local operator.
|
||||
|
||||
## Devii user-defined ("virtual") tools
|
||||
|
||||
Users invent new Devii tools in natural language ("when I say woeii, do Y"); each is stored per-owner and added to Devii's live LLM tool list, and when called its handler **re-prompts Devii itself** (a self-eval sub-agent) with the stored prompt plus the user's single free-form `input`. Full CRUD is Devii-only (`tool_create`/`tool_list`/`tool_get`/`tool_update`/`tool_delete`, `handler="virtual_tool"`).
|
||||
|
||||
- **Dynamic tool list (the crux).** The session tool list is normally frozen (`session.py` `self.tools = CATALOG.tool_schemas_for(...)`, handed by reference to `Agent`, `AgenticController.bind`, and read live by `react_loop` each `llm.complete`). `DeviiSession._refresh_tools()` runs at the top of every `_run_turn` (inside `self._lock`, before `agent.respond`) and rewrites that list **in place**: `self.tools[:] = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin) + self._virtual_tool_store.tool_schemas()`. Because every holder shares the same list object, a tool created/edited/deleted (and a mid-session login) takes effect on the **next** turn with no Agent rebuild. **Never reassign `self.tools`, always mutate in place.**
|
||||
- **Self-eval engine** (`agentic/controller.py`). `_delegate` was refactored onto `_spawn(prompt, tools, system_prompt)`, which runs `react_loop` with a fresh `messages`/`AgentState` under a **depth guard** (`agentic/state.py` `get/set/reset_eval_depth`, `MAX_EVAL_DEPTH=2`) so delegate/eval/virtual tools cannot self-call infinitely. `run_subagent(prompt)` (tools minus `delegate`) is the public engine; the `eval` agentic tool wraps it; `_delegate` keeps its richer JSON report.
|
||||
- **Store** (`services/devii/virtual_tools/store.py`). `VirtualToolStore(db, owner_kind, owner_id)` over `devii_virtual_tools` (`uid, owner_kind, owner_id, name, description, prompt, input_description, enabled, created_at, updated_at`; indexes `idx_devii_vtools_owner` / `idx_devii_vtools_name`). `tool_schemas()` builds, for each **enabled** row, an LLM schema with a single free-form `input` string. Persistent for users, `memory_db()` for guests (built in `hub.get_or_create` from the shared `owned_db`).
|
||||
- **Controller** (`virtual_tools/controller.py`). `VirtualToolController(store, evaluator, builtin_names)`, evaluator = `agentic.run_subagent`, `builtin_names = set(CATALOG.by_name())`. CRUD `tool_create/list/get/update/delete` (`handler="virtual_tool"`); `tool_create` validates name `^[a-zA-Z][a-zA-Z0-9_]{1,40}$`, rejects built-in collisions and duplicates, requires `description`+`prompt`. `has(name)` / `run(name, args)` compose `f"{prompt}\n\nUser input: {input}"` and call the evaluator.
|
||||
- **Dispatch** (`actions/dispatcher.py`). Constructed with `virtual_tools=...`; `_run` routes `handler="virtual_tool"` to CRUD; the **unknown-name branch** (`if action is None`) resolves a virtual tool via `self._virtual_tools.has(name)` -> `run(name, args)` before erroring. So virtual tools live only in the store (not the static catalog) and are resolved on demand; the static `_actions` stays built-in only.
|
||||
- Registered via `VIRTUAL_TOOL_ACTIONS` (`registry.py`); the `eval` tool via `AGENTIC_ACTIONS`. System-prompt **USER-DEFINED TOOLS (VIBE TOOLS)** section steers creation and warns against deep self-calls.
|
||||
|
||||
## Devii self-configured behavior (`services/devii/behavior/`)
|
||||
|
||||
Devii can partially configure its OWN system message: every system prompt ends with a `# TRUTH RULES AND BEHAVIOR` section (the `BEHAVIOR_HEADER` constant in `session.py`) whose body is an owner-scoped, persistent set of behavior rules. When the user tells Devii to behave differently, says they expect different behavior, or Devii upsets them, Devii calls the **`update_behavior`** tool (`handler="behavior"`, `requires_auth=False`, single `behavior` arg = the FULL new section content) to persist the change. It is **self-prompted only**: the sole way to change the section is Devii calling that tool - there is no HTTP route or UI, like `LessonStore`/customization. Because Devii already SEES the current section in its own system message, it merges (copy current rules, apply the change, send the whole result) so prior rules are not lost; this is why the tool replaces rather than appends.
|
||||
|
||||
- **Store** (`behavior/store.py`). `BehaviorStore(db, owner_kind, owner_id)` over `devii_behavior` (one upserted row per owner keyed on `owner_kind`/`owner_id`; `text()` reads, `set()` upserts; index `idx_devii_behavior_owner`). Persistent for users, `memory_db()` for guests (built in `hub.get_or_create` from the shared `owned_db`, like the other owner stores).
|
||||
- **Controller** (`behavior/controller.py`). `BehaviorController(store)`, `dispatch("update_behavior", args)` -> `store.set(behavior)`. Built in `DeviiSession` and passed to `Dispatcher(behavior=...)`; the dispatcher routes `handler="behavior"` to it and degrades gracefully ("not available in this context") when unwired (e.g. the standalone CLI, same as `virtual_tools`/`avatar`).
|
||||
- **Injection and refresh** (`session.py`). `_compose_system_prompt()` = base prompt (`_system_prompt_for(is_admin)`) + `\n\n` + `BEHAVIOR_HEADER` (+ `\n` + body when non-empty; just the header when empty). Used to seed the `Agent` and the scheduler executor worker, and `_refresh_system_prompt()` rewrites `agent._messages[0]["content"]` **at the top of every `_run_turn`** (next to `_refresh_tools`), so a mid-conversation `update_behavior` takes effect on the following turn and the live system message is never overridden by the stale base.
|
||||
- Registered via `BEHAVIOR_ACTIONS` (`registry.py`); the system-prompt **SELF-CONFIGURED BEHAVIOR (TRUTH RULES)** section in `agent.py` steers when/how to call it.
|
||||
|
||||
## Related Devii tool wrappers (customization, container)
|
||||
|
||||
Two more Devii-side controllers live under `services/devii/` but their full mechanism is documented in their owning subsystem's file, not here:
|
||||
|
||||
- **Per-user customization tools** (`services/devii/customization/`, `handler="customization"`): `customize_list`/`get`/`set_css`/`set_js`/`reset` (all `requires_auth=False`) plus `customize_set_enabled` (`requires_auth=True`, the suppression toggles). `CustomizationController(owner_kind, owner_id)` is built in `Dispatcher.__init__`; set/reset are in `CONFIRM_REQUIRED` so `confirmation_error` forces Devii to ask **page-type vs global** before `confirm=true`. Devii previews live with `run_js` (ids `devii-preview-css`/`devii-preview-js`) and `reload_page` after saving. For the storage model, injection pipeline, and per-user suppression flags, see `devplacepy/customization.py` and its own documentation.
|
||||
- **Container tools** (`services/devii/container/ContainerController`, `handler="container"`): wraps `services/containers/api.py`, the same operations shared by `routers/containers.py`. For the reconciler, backend ABC, ingress proxy, and workspace sync mechanism, see `devplacepy/services/containers/CLAUDE.md`.
|
||||
@@ -108,58 +108,4 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
|
||||
params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="isslop",
|
||||
method="POST",
|
||||
path="/tools/isslop/run",
|
||||
summary="Classify a repository or website as AI slop or human work",
|
||||
description=(
|
||||
"Queues a background AI Usage Analyzer job and returns {uid, status_url, report_url}. "
|
||||
"Poll the status with isslop_status until status is 'completed', then share the "
|
||||
"authenticity grade, category, human/AI split and report_url. Accepts http(s), "
|
||||
"git and ssh source URLs."
|
||||
),
|
||||
params=(
|
||||
body("url", "Repository or website URL to classify.", required=True),
|
||||
),
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="isslop_status",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}",
|
||||
summary="Check a AI usage analysis and obtain its verdict once finished",
|
||||
description=(
|
||||
"Returns the analysis status. When status is 'completed', grade, category, "
|
||||
"human_percent, ai_percent and report_url are populated; while 'pending' or "
|
||||
"'running', poll again shortly."
|
||||
),
|
||||
params=(path("uid", "Analysis uid returned by isslop."),),
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="isslop_report",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report",
|
||||
summary="Read a finished AI usage analysis report",
|
||||
description=(
|
||||
"Returns the full report for a finished analysis: authenticity grade, human/AI "
|
||||
"split, markdown findings, per-file scores with signals, the image review and the "
|
||||
"embeddable badge snippets. Use it after isslop_status reports status 'completed'."
|
||||
),
|
||||
params=(path("uid", "Analysis uid returned by isslop."),),
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="isslop_list",
|
||||
method="GET",
|
||||
path="/tools/isslop/list",
|
||||
summary="List the user's AI usage analyses",
|
||||
description=(
|
||||
"Returns the signed-in user's analysis history, newest first, each with its grade, "
|
||||
"category, status and report_url."
|
||||
),
|
||||
params=(),
|
||||
requires_auth=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"Optional DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
|
||||
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
|
||||
),
|
||||
arg(
|
||||
"start_on_boot",
|
||||
@@ -118,7 +118,7 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
|
||||
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
|
||||
),
|
||||
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
|
||||
arg("boot_script", "Boot source code body run on launch."),
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(
|
||||
name: str, description: str, required: bool = False, kind: str = "string"
|
||||
) -> Param:
|
||||
return Param(
|
||||
name=name,
|
||||
location="body",
|
||||
description=description,
|
||||
required=required,
|
||||
type=kind,
|
||||
)
|
||||
|
||||
|
||||
SLUG = arg(
|
||||
"project_slug",
|
||||
"Project slug or uid that owns the container resources.",
|
||||
required=True,
|
||||
)
|
||||
|
||||
CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_list_instances",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="List a project's container instances and their status",
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="container_create_instance",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("name", "Instance name.", required=True),
|
||||
arg(
|
||||
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
|
||||
),
|
||||
arg(
|
||||
"boot_language",
|
||||
"Optional boot source language: 'none', 'python', or 'bash'. When set with boot_script, the script is materialized into /app and run on launch (takes precedence over boot_command).",
|
||||
),
|
||||
arg(
|
||||
"boot_script",
|
||||
"Optional boot source code (the body of the python or bash script) run on launch when boot_language is python or bash.",
|
||||
),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
|
||||
),
|
||||
arg(
|
||||
"start_on_boot",
|
||||
"Force this instance to running whenever the container service starts ('true' or 'false', default false).",
|
||||
),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
arg("env", "Optional env vars as KEY=VALUE lines."),
|
||||
arg(
|
||||
"ports",
|
||||
"Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one.",
|
||||
),
|
||||
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
|
||||
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
|
||||
arg("autostart", "Start immediately ('true' or 'false', default true)."),
|
||||
arg(
|
||||
"ingress_slug",
|
||||
"Optional public ingress slug; the service is then reachable at /p/<slug>.",
|
||||
),
|
||||
arg(
|
||||
"ingress_port",
|
||||
"Container port to publish at /p/<slug> (must be one of the mapped ports).",
|
||||
kind="integer",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_instance_action",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
|
||||
description="sync imports the container /app workspace back into the project files.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"action",
|
||||
"start, stop, restart, pause, resume, delete, or sync.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
"confirm",
|
||||
"Required only for action=delete: set true ONLY after the user has explicitly "
|
||||
"confirmed destroying the instance. Leave unset otherwise; the delete is refused "
|
||||
"until you pass confirm=true.",
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_configure_instance",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"run_as_uid",
|
||||
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
|
||||
),
|
||||
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
|
||||
arg("boot_script", "Boot source code body run on launch."),
|
||||
arg("boot_command", "Fallback boot command used when no boot_script is set."),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
arg(
|
||||
"start_on_boot",
|
||||
"Force running on container-service start ('true' or 'false').",
|
||||
),
|
||||
arg("cpu_limit", "CPU limit, e.g. 1 or 1.5."),
|
||||
arg("mem_limit", "Memory limit, e.g. 512m or 1g."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_logs",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="Read the recent logs of a running instance",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("tail", "Number of log lines (default 200).", kind="integer"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_exec",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Run a one-shot command inside a running instance and return its output. The command runs in /app (the project workspace) by default, so never prefix it with 'cd /app'",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg(
|
||||
"command",
|
||||
"Command to run, e.g. 'git clone ... && ls'. Runs in /app already; do not prepend 'cd /app'.",
|
||||
required=True,
|
||||
),
|
||||
arg(
|
||||
"confirm",
|
||||
"Required only when the command is destructive (rm, dd, truncate, drop, etc.): set "
|
||||
"true ONLY after the user has explicitly confirmed. Leave unset otherwise; a "
|
||||
"destructive command is refused until you pass confirm=true.",
|
||||
kind="boolean",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_stats",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
summary="Get aggregated resource and runtime statistics for an instance",
|
||||
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_schedule",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("action", "start or stop.", required=True),
|
||||
arg("kind", "once, interval, or cron.", required=True),
|
||||
arg("cron", "Cron expression for kind=cron, e.g. '0 2 * * *'."),
|
||||
arg("run_at", "ISO time for kind=once, e.g. 2026-06-15T02:00:00."),
|
||||
arg("every_seconds", "Interval seconds for kind=interval.", kind="integer"),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -287,7 +287,7 @@ class Dispatcher:
|
||||
self._rsearch = RsearchController(settings, owner_kind, owner_id)
|
||||
from ..container import ContainerController
|
||||
|
||||
self._container = ContainerController(client, owner_id=owner_id)
|
||||
self._container = ContainerController(client)
|
||||
from ..customization import CustomizationController
|
||||
|
||||
self._customization = CustomizationController(owner_kind, owner_id)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import get_table, get_users_by_uids, resolve_by_slug
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.services.containers import api, store
|
||||
from devplacepy.services.containers.api import ContainerError
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
@@ -15,9 +15,8 @@ logger = logging.getLogger("devii.container")
|
||||
|
||||
|
||||
class ContainerController:
|
||||
def __init__(self, client: Any = None, owner_id: str = "") -> None:
|
||||
def __init__(self, client: Any = None) -> None:
|
||||
self._client = client
|
||||
self._owner_id = owner_id
|
||||
|
||||
def _ingress_url(self, instance: dict):
|
||||
slug = instance.get("ingress_slug")
|
||||
@@ -30,37 +29,21 @@ class ContainerController:
|
||||
|
||||
def _actor_user(self) -> dict:
|
||||
username = getattr(self._client, "username", None)
|
||||
if username and username != "api-key":
|
||||
if username:
|
||||
user = get_table("users").find_one(username=username)
|
||||
if user:
|
||||
return user
|
||||
if self._owner_id:
|
||||
user = get_users_by_uids([self._owner_id]).get(self._owner_id)
|
||||
if user:
|
||||
return user
|
||||
return {
|
||||
"uid": "admin",
|
||||
"username": username or "admin",
|
||||
"role": "Admin",
|
||||
}
|
||||
return {"uid": "admin", "username": username or "admin"}
|
||||
|
||||
def _project(self, arguments: dict) -> dict:
|
||||
from devplacepy.content import can_view_project_containers
|
||||
from devplacepy.content import can_view_project
|
||||
|
||||
slug = str(arguments.get("project_slug", "")).strip()
|
||||
project = resolve_by_slug(get_table("projects"), slug) if slug else None
|
||||
if not project or not can_view_project_containers(project, self._actor_user()):
|
||||
if not project or not can_view_project(project, self._actor_user()):
|
||||
raise ToolInputError(f"project not found: {slug}")
|
||||
return project
|
||||
|
||||
def _require_manage(self, project: dict, inst: dict) -> None:
|
||||
from devplacepy.content import can_manage_instance
|
||||
|
||||
if not can_manage_instance(inst, project, self._actor_user()):
|
||||
raise ToolInputError(
|
||||
"only the container owner or the primary administrator can manage this instance"
|
||||
)
|
||||
|
||||
def _instance(self, project: dict, ref: str) -> dict:
|
||||
inst = store.get_instance(ref)
|
||||
if inst is None or inst["project_uid"] != project["uid"]:
|
||||
@@ -129,7 +112,6 @@ class ContainerController:
|
||||
async def _instance_action(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
inst = self._instance(project, str(arguments.get("instance", "")))
|
||||
self._require_manage(project, inst)
|
||||
action = str(arguments.get("action", "")).lower()
|
||||
actor = ("user", self._actor_user()["uid"])
|
||||
if action == "delete":
|
||||
@@ -152,7 +134,6 @@ class ContainerController:
|
||||
async def _configure_instance(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
inst = self._instance(project, str(arguments.get("instance", "")))
|
||||
self._require_manage(project, inst)
|
||||
actor = ("user", self._actor_user()["uid"])
|
||||
kwargs: dict = {}
|
||||
for key in (
|
||||
@@ -198,7 +179,6 @@ class ContainerController:
|
||||
async def _exec(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
inst = self._instance(project, str(arguments.get("instance", "")))
|
||||
self._require_manage(project, inst)
|
||||
if not inst.get("container_id"):
|
||||
raise ToolInputError("instance is not running")
|
||||
command = str(arguments.get("command", "")).strip()
|
||||
@@ -234,7 +214,6 @@ class ContainerController:
|
||||
async def _schedule(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
inst = self._instance(project, str(arguments.get("instance", "")))
|
||||
self._require_manage(project, inst)
|
||||
run_at = arguments.get("run_at")
|
||||
try:
|
||||
schedule = Schedule(
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
This file documents the email (IMAP/SMTP) subsystem. Claude Code auto-loads it when a file under `devplacepy/services/email/` is read or edited.
|
||||
|
||||
## Email via Devii (`services/email/` + `services/devii/email/`)
|
||||
|
||||
A signed-in user's Devii can connect to their OWN external mailbox over IMAP/SMTP (non-platform, external protocol tools).
|
||||
|
||||
### Protocol engine and adapter
|
||||
|
||||
The protocol engine `services/email/` (`EmailAccount` dataclass + `EmailClient`) is built over the Python **stdlib** `imaplib`/`smtplib`/`email` - no new dependency, since IMAP/SMTP are not HTTP the stealth-client rule does not apply. It is wrapped by the Devii adapter `services/devii/email/EmailController` (`handler="email"`, registered in `registry.py`, specs in `actions/email_actions.py`, routed in `dispatcher.py`). Every blocking protocol call runs through `asyncio.to_thread` in the controller so the single worker loop never stalls (same discipline as `correction.py`).
|
||||
|
||||
### Tools
|
||||
|
||||
- Connection CRUD: `email_account_set`/`email_account_get`/`email_accounts_list`/`email_account_delete`.
|
||||
- Message ops: `email_list_folders`, `email_list_messages`, `email_search`, `email_read_message` (read); `email_mark`/`email_set_flags`/`email_move_message` (organise); `email_delete_message`; `email_send`.
|
||||
|
||||
All are `requires_auth=True` AND additionally guarded to `owner_kind == "user"` (guests never reach email), gated by the admin `devii_email_enabled` flag (default on) / `devii_email_timeout` on the Devii service config ("Email" service-config group).
|
||||
|
||||
### Credentials
|
||||
|
||||
Credentials live in the soft-deletable per-owner `email_accounts` table (one row per `(owner_kind, owner_id, label)`; helpers `list/get/set/delete_email_account` in `database/`, sensible defaults imap 993/ssl + smtp 587/starttls applied in `set_email_account`). The password is stored **plaintext** (consistent with `api_key`/`gateway_api_key`) and is **NEVER echoed back** - `email_account_get`/`email_accounts_list` mask it as `password_set`.
|
||||
|
||||
### Configuration surface
|
||||
|
||||
Configuration is **Devii-only** (no HTTP route or profile UI), like the CSS/JS customization feature.
|
||||
|
||||
### Destructive actions and SSRF guard
|
||||
|
||||
`email_send` is a real outbound action, and the two delete tools (`email_account_delete`, `email_delete_message`) are confirmation-gated (`CONFIRM_REQUIRED`, each declaring a `confirm` param). The IMAP/SMTP host is run through `net_guard.guard_public_host_sync` before every connect (private/loopback addresses refused - SSRF/internal-scan defense, mirroring `guard_public_url`).
|
||||
|
||||
### Audit and metering
|
||||
|
||||
Audited via the existing `_audit_mechanic` choke point under the `email.*` domain (category `email`). Mutations are **not metered into the gateway ledger** (no LLM call is made).
|
||||
@@ -1,57 +0,0 @@
|
||||
# Code Farm game (`devplacepy/services/game/`, `devplacepy/routers/game/`)
|
||||
|
||||
This file documents the Code Farm idle game. Claude Code auto-loads it when a file under `devplacepy/services/game/` is read or edited.
|
||||
|
||||
## Overview
|
||||
|
||||
`game/` package (routers, mounted at `/game`) is the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,perk,prestige,legacy,quests/claim}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`.
|
||||
|
||||
A cooperative-and-competitive idle game (Farmville-style) mounted at `/game`, member-only to play, public to view another farm. Cooperative loop = watering a neighbour's growing build; competitive loop = stealing a neighbour's ready build.
|
||||
|
||||
## Data layer
|
||||
|
||||
**Data layer is pure + timestamp-driven (no background tick).** `services/game/economy.py` holds every constant and formula as frozen dataclasses/functions: the `CROPS` tuple (key/name/icon/cost/grow_seconds/reward_coins/reward_xp/min_level), `CI_TIERS` (speed multiplier + upgrade cost), level thresholds (`xp_threshold`/`level_for_xp`/`level_progress`), `plot_cost` (doubles per extra plot), and watering bonus. `services/game/store.py` is the only DB access (`game_farms`, `game_plots`). **A plot's state is derived from `ready_at` vs now, never stored** - growing crops finish purely by the clock, so there is no reconciler/service. `serialize_farm(farm, viewer=, owner=)` computes plot states, remaining seconds, `can_water` (viewer is not owner, build growing, viewer not already in the per-cycle `watered_by` JSON list, under `MAX_WATERS_PER_PLOT`), `can_steal`/`steal_coins` (viewer is not owner, build ready, and `now >= ready_at + STEAL_GRACE_SECONDS` so the owner gets a protection window), level progress, and the plantable crop list. `serialize_plot` takes the owner farm's `yield_level`/`prestige` (threaded from `serialize_farm`) only to compute the steal payout. Mutations (`plant`/`harvest`/`buy_plot`/`upgrade_ci`/`water`/`steal`) raise `GameError` on any invalid op (insufficient coins, locked crop, wrong state, still-protected harvest); the routers translate that to a `400` JSON error or a redirect.
|
||||
|
||||
## Tables
|
||||
|
||||
`game_farms` (one per user, `coins`/`xp`/`level`/`ci_tier`/`plot_count`/`total_harvests`, plus `prestige`/`streak`/`last_daily_at`, the four `perk_*` columns, and the endgame `stars` + five `legacy_*` columns), `game_plots` (`farm_uid`/`slot_index`/`crop_key`/`planted_at`/`ready_at`/`watered_by`), and `game_steals` (`thief_uid`/`owner_uid`/`slot_index`/`crop_key`/`coins`/`stolen_at`, the per-pair steal-cooldown ledger) are **not** soft-deletable - they are mutable game state consumed by state transitions, not user content. Columns + indexes are ensured in `database.init_db` (unique `idx_game_farms_user`, `idx_game_farms_rank`, `idx_game_plots_farm`, `idx_game_steals_pair` on `(thief_uid, owner_uid, stolen_at)`); the `_uid_index` loop adds the uid index. `ensure_farm(user_uid)` lazily creates a farm + starting plots on first access (idempotent), so there is no signup hook.
|
||||
|
||||
## Routes (`routers/game/`)
|
||||
|
||||
`index.py` is the base router - `GET /game` (own farm page), `GET /game/state` (own farm JSON), `GET /game/leaderboard`, and the action POSTs `plant`/`harvest`/`buy-plot`/`upgrade`. `farm.py` adds `GET /game/farm/{username}` (view), `POST /game/farm/{username}/water`, and `POST /game/farm/{username}/steal`. Every action returns the **full updated farm** as JSON (so the client refreshes in one round trip) or redirects for no-JS, via the shared `_respond_action` choke (the `farm.py` water/steal handlers inline the same shape). Harvest awards site XP (`award_rewards`) and the `harvest`/`water` achievements (`track_action`). All action handlers are `require_user`; reads of other farms/the leaderboard are public.
|
||||
|
||||
**Leaderboard ranking is a composite `economy.farm_score(farm)`** (one integer per row, computed in memory over the already-loaded `_farms().find()` set, so it stays fast): it sums weighted contributions from every tracked factor - `xp`, `prestige` * `SCORE_PRESTIGE` (5000, dominant since a refactor is a full completed cycle), `total_harvests` * `SCORE_HARVEST`, `coins` // `SCORE_COIN_DIVISOR`, `(ci_tier-1)` * `SCORE_CI`, `(plot_count-STARTING_PLOTS)` * `SCORE_PLOT`, the summed perk levels * `SCORE_PERK`, and `min(streak, SCORE_STREAK_CAP)` * `SCORE_STREAK` - so a player who refactored (which resets xp/level/coins/ci/plots/perks) is no longer buried below a never-refactored higher-level player. The weights are module-level constants in `economy.py` for tuning; the leaderboard entry carries `score` and `prestige` (surfaced on `GameLeaderboardEntryOut`) and `GameFarm.js` renders the score next to `Lv X`.
|
||||
|
||||
## Live + frontend
|
||||
|
||||
After any mutation the handler `await`s `_shared.notify_farm(username)` which publishes a nudge to the `public.game.farm.{username}` pub/sub topic; subscribed clients re-fetch their viewer-specific state (keeps `can_water` correct without broadcasting per-viewer payloads). `static/js/GameFarm.js` (`app.gameFarm`, auto-detects `[data-game-root]`) renders the grid/HUD/leaderboard, ticks plot countdowns client-side every second, delegates the `data-game-action` forms through `Http.send`, subscribes to the farm topic, and keeps a 20s `Poller` fallback. The server renders a full no-JS fallback grid (`templates/_game_grid.html`, shared by `game.html` and `game_farm.html` with progressive-enhancement POST forms).
|
||||
|
||||
## Fan-out
|
||||
|
||||
Devii plays via the `game_*` `http` actions in `actions/catalog.py` (state/leaderboard/view public, the rest `requires_auth`); the API reference has a **Code Farm** group in `docs_api.py`; pages are `noindex,follow` (interactive, user-specific) so they are intentionally not in the sitemap; badges live in `utils.BADGE_CATALOG` under the **Code Farm** group with `harvest`/`water`/`harvest_stolen`/`got_stolen_from` `ACHIEVEMENTS` (the steal pair awards **Cat Burglar** to the thief and **Robbed** to the victim, both threshold 1).
|
||||
|
||||
## Stealing (competitive loop, backwards compatible)
|
||||
|
||||
`store.steal(thief, owner, slot)` mirrors `water`: it requires the owner plot to be `ready` AND past the protection window `economy.effective_steal_grace(owner_defense_level)` (base `STEAL_GRACE_SECONDS` 60s, +30s per owner Branch Protection level) measured from `ready_at`, AND that the thief is **off cooldown for this victim** (`steal_cooldown_remaining(thief, owner, now) == 0`, i.e. no row in `game_steals` for the pair within `STEAL_COOLDOWN_SECONDS` = 3600 - you can raid a given neighbour only once per hour); it clears the plot exactly like a harvest (owner gets nothing), credits the **thief's** farm `economy.steal_reward_coins` (the owner's yield/prestige/legacy-multiplier realized value times `effective_steal_fraction(owner_defense_level)`, base `STEAL_FRACTION` 0.5, -5% per defense level, floored at 0.1) and **coins only** - no XP, no `total_harvests`, so the leaderboard stays earned by real farming - then inserts a `game_steals` row stamping the cooldown. The route `POST /game/farm/{username}/steal` (reusing `GameSlotForm`) then `track_action`s both sides, fires `create_notification(owner, "harvest_stolen", "Someone raided your Code Farm...", thief_uid, "/game")` (the message never names the thief; `related_uid` is internal), and `await notify_farm`s **both** the owner and the thief usernames so both farms refresh live. The new `harvest_stolen` notification type rides the existing in-app relay (live toast, no new wiring). No DB migration: grace + payout are computed from existing `ready_at`/perk/legacy columns, the `game_steals` table is created by the ensure-block (absent rows -> cooldown 0 -> first steal always allowed), and the new `GamePlotOut.can_steal`/`steal_coins`/`steal_cooldown_seconds`/`steal_reason` + `GameFarmOut.steal_cooldown_seconds` + `GameFarmViewOut.stole_coins` schema fields default safe. The per-pair cooldown is computed **once per farm** in `serialize_farm` (when the viewer is not the owner) and threaded into every `serialize_plot` as `steal_locked_until`, so `can_steal` is false and `steal_reason` is `"cooldown"`/`"protected"` accordingly. Frontend: the ready/not-owner branch of `_game_grid.html` and `GameFarm.js._plotHtml` render a `btn-danger` Steal button (`data-game-action="steal"`, `data-confirm` gated like prestige) carrying `steal_coins`, or a disabled "Raid again in <countdown>" label when on cooldown; on success `GameFarm.js._submit` toasts the payout from `data.stole_coins`.
|
||||
|
||||
## Extended mechanics (all backwards compatible)
|
||||
|
||||
Five further systems layer onto the base loop, every one defaulting gracefully for pre-existing `game_farms`/`game_plots` rows: new `game_farms` columns (`prestige`, `streak`, `last_daily_at`, `perk_yield`/`perk_growth`/`perk_discount`/`perk_xp`) are added in the `init_db` ensure-block, and `store._lvl(farm, key)` reads every one as `int(farm.get(key) or 0)` so a legacy NULL row behaves as level 0 / no streak / no perks.
|
||||
|
||||
1. **Daily bonus** (`POST /game/daily`, `store.claim_daily`): once per UTC day, consecutive days grow `streak` (reward `economy.daily_reward`, capped at `DAILY_STREAK_CAP`).
|
||||
2. **Daily quests** (`game_quests` table, `POST /game/quests/claim`): `economy.daily_quests(user_uid, day)` deterministically (sha256 of `user:day`) picks 3 of {plant, harvest, water, earn} with goals/rewards; `store.ensure_quests` lazily materializes the day's rows, `store.advance_quests(user_uid, kind, amount)` is called inside `plant`/`harvest`/`water` (wrapped in try/except so a quest write never breaks the action), `claim_quest` pays out when `progress >= goal`.
|
||||
3. **Perks** (`POST /game/perk`, `store.upgrade_perk`): four permanent upgrades (`economy.PERKS`) with escalating `perk_cost`; applied in the economy formulas - `effective_plant_cost` (discount), `grow_seconds_for(crop, ci_tier, growth_level)` via `farm_speed` (growth), `effective_reward_coins` (yield + prestige), `effective_reward_xp` (xp).
|
||||
4. **Fertilizer** (`POST /game/fertilize`, `store.fertilize`): cut a growing plot's `ready_at` by `FERTILIZE_FRACTION` for `economy.fertilize_click_cost(eff_reward, reduce_seconds, full_grow_seconds)` = `ceil(eff_reward * reduce/full_grow * FERTILIZE_TAX)` (TAX 1.05). **The cost is priced against the build's realized harvest value (`effective_reward_coins`, which already carries yield/prestige/legacy multipliers), not raw grow-seconds, so the prestige dependence cancels and fully fertilizing a crop always costs >= its harvest - fertilize is a pure time-skip and can NEVER be a profit at any prestige.** (This replaced the old grow-seconds-based `fertilize_cost`, which was an unbounded money pump at high prestige.)
|
||||
5. **Prestige/Refactor** (`POST /game/prestige`, `store.prestige`): at `PRESTIGE_MIN_LEVEL` resets coins/xp/level/ci/perks, sets `plot_count = economy.prestige_base_plots(legacy_plots_level)` (keeps/recreates plot rows up to that base, deletes the rest), increments `prestige` for a permanent `prestige_multiplier` (+25% coins each), and awards `economy.stars_for_refactor(level, prestige)` Stars (the `legacy_*`/`stars` columns are **omitted from the reset dict** so they survive every refactor, the established pattern).
|
||||
|
||||
`serialize_farm` exposes all of this (`perks`, `quests`, `streak`, `daily_available`/`daily_reward`, `prestige*`, `stars`, `legacy`, `steal_cooldown_seconds`) only to the owner; the existing `crop_payload`/`serialize_plot` now carry perk- and legacy-adjusted costs/rewards and per-plot value-based `fertilize_cost`. Frontend hosts (`[data-shop-host]`/`[data-perk-host]`/`[data-legacy-host]`/`[data-daily-host]`/`[data-quest-host]`) are server-rendered from partials (`_game_shop.html`/`_game_perks.html`/`_game_legacy.html`/`_game_daily.html`/`_game_quests.html`) and fully re-rendered by `GameFarm.js` builders; the generic `data-game-action` form delegation handles the new actions, with `data-confirm` gating the prestige reset.
|
||||
|
||||
## Endgame: Stars, Legacy upgrades, auto-harvest, golden builds (all backwards compatible)
|
||||
|
||||
The infinite progression for maxed farms. Six new default-0 `game_farms` columns (`stars`, `legacy_autoharvest`, `legacy_multiplier`, `legacy_speed`, `legacy_plots`, `legacy_defense`), all read via `_lvl`.
|
||||
|
||||
**Stars** are a meta-currency earned only on Refactor (`stars_for_refactor`), spent via `POST /game/legacy` (`store.upgrade_legacy`, `GameLegacyForm`, Devii `game_upgrade_legacy`) on `economy.LEGACY_UPGRADES` (escalating `legacy_cost` in Stars) that **survive prestige** unlike perks: `autoharvest` (CI Bot), `multiplier` (+10% coins/lvl via `legacy_multiplier`, folded into `effective_reward_coins`), `speed` (+5% base build speed/lvl via `farm_speed`/`grow_seconds_for`/`water_bonus_seconds`), `plots` (+1 base plot after refactor via `prestige_base_plots`), `defense` (steal grace/fraction via `effective_steal_grace`/`effective_steal_fraction`).
|
||||
|
||||
**Auto-harvest** is lazy and tick-free: `store._auto_harvest(farm, owner_uid, now)` runs at the top of `serialize_farm` ONLY when the viewer is the owner AND `legacy_autoharvest > 0`; it **clears each ready plot first then credits** the pre-clear crop's coins/xp/`total_harvests` in one `_update_farm`, advances quests, and re-reads the farm - clear-then-credit is idempotent (a second read sees empty plots), and it never fires on a visitor's `GET /game/farm/{username}` view or the leaderboard, honouring the no-background-service invariant (both `GET /game` and `GET /game/state` go through `state_payload` with viewer=owner, so both auto-collect).
|
||||
|
||||
**Golden builds** are deterministic and storage-free: `economy.is_golden(plot_uid, planted_at)` (sha256, ~`GOLDEN_CHANCE`) marks a planting golden for its life; `harvest`/`_auto_harvest` multiply **coins only** by `GOLDEN_MULTIPLIER` (XP unscaled to keep level pacing), and `serialize_plot.is_golden` surfaces a sparkle badge (`.game-plot-golden`). New schema fields (`GameLegacyOut`, `GameFarmOut.stars`/`legacy`, `GamePlotOut.is_golden`) all default safe; no DB migration.
|
||||
@@ -2,16 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
from .. import economy
|
||||
from .common import GameError, _farms, _iso, _now, _plots, _update_farm
|
||||
|
||||
|
||||
_leaderboard_cache = TTLCache(ttl=15, max_size=8)
|
||||
|
||||
|
||||
def get_farm(user_uid: str) -> dict | None:
|
||||
return _farms().find_one(user_uid=user_uid)
|
||||
|
||||
@@ -99,9 +95,6 @@ def upgrade_ci(user: dict) -> dict:
|
||||
|
||||
|
||||
def leaderboard(limit: int = 25) -> list[dict]:
|
||||
cached = _leaderboard_cache.get(f"top:{limit}")
|
||||
if cached is not None:
|
||||
return cached
|
||||
farms = sorted(
|
||||
_farms().find(),
|
||||
key=economy.farm_score,
|
||||
@@ -129,5 +122,4 @@ def leaderboard(limit: int = 25) -> list[dict]:
|
||||
"score": economy.farm_score(farm),
|
||||
}
|
||||
)
|
||||
_leaderboard_cache.set(f"top:{limit}", entries)
|
||||
return entries
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
This file documents the Gitea issue-tracker integration subsystem. Claude Code auto-loads it when a file under `devplacepy/services/gitea/` is read or edited.
|
||||
|
||||
## Issue tracker (`services/gitea/`)
|
||||
|
||||
The `/issues` feature is a **full Gitea integration with no local issue store** - there is no local issue store; the listing and detail views read issues straight from Gitea (default repo `retoor/devplacepy` on `retoor.molodetz.nl`) with live status. All connection settings live in `site_settings` and are admin-editable on the `IssueTrackerService` config at `/admin/services`.
|
||||
|
||||
### Package `services/gitea/`
|
||||
|
||||
- `config.py` - the admin-editable `CONFIG_FIELDS` (Gitea base URL/owner/repo/token, AI enhance toggle/model/key) plus `gitea_config()` -> frozen `GiteaConfig` (with `api_base`/`repo_base`/`is_configured`) and `is_configured()`. The token is one shared bot account; per-user attribution is done by storing names in DevPlace, not by per-user Gitea accounts.
|
||||
- `client.py` - async `GiteaClient` over the Gitea REST API (`Authorization: token`): `create_issue`, `list_issues(state,page,limit)` returning `(issues, total)` from `X-Total-Count` and filtering out pull requests, `get_issue`, `list_comments`, `create_comment`, `set_state`. Raises `GiteaError(message, status)` on any non-2xx or transport error.
|
||||
- `fake.py` - in-memory `FakeGiteaClient` mirroring the same async interface (plus `add_external_comment` to simulate a developer reply); the test backend/double.
|
||||
- `runtime.py` - `get_client()` / `set_client()` swap (tests inject the fake). `get_client()` builds a fresh `GiteaClient` from current config unless overridden.
|
||||
- `enhance.py` - `enhance_ticket(title, description, config)` rewrites a raw report into a consistent markdown ticket via the **internal AI gateway** (`INTERNAL_GATEWAY_URL`, key defaults to the gateway internal key): a level-2-heading markdown body (`Summary` / `Steps to Reproduce` / `Expected Behaviour` / `Actual Behaviour` / `Environment`) and a tightened title. It is fail-soft to a deterministic template: any error or unparseable response falls back to a deterministic template built from the original text (`EnhancedTicket.enhanced=False`). This is the AI attachment point - the gateway, like every other AI consumer.
|
||||
- `store.py` - the **only DB state**, a thin local mapping: `issue_tickets` (gitea_number -> author_uid + original text + cached `last_status`/`last_comment_count` for update detection) and `issue_comment_authors` (gitea_comment_id -> author_uid, for display attribution and to tell DevPlace-authored comments from developer replies). Helpers: `record_ticket`, `get_ticket`, `author_uid_for_issue`, `author_map`, `record_comment_author`, `comment_author_map`, `local_comment_ids`, `tracked_tickets`, `update_ticket_cache`. Both tables are in `SOFT_DELETE_TABLES`; `init_db` ensures their full column set + indexes (so queries are safe on a fresh DB before any insert).
|
||||
- `service.py` - `IssueTrackerService(BaseService)` (`default_enabled=False`, holds `CONFIG_FIELDS`), polls tracked tickets and notifies the **reporter** on developer replies / status changes. Each tick it polls every tracked ticket: when the Gitea comment count grew, it loads comments and notifies the reporter if any of the new tail comments are **not** in `local_comment_ids` (a developer reply); when the state changed it notifies the reporter (closed/reopened). It then updates the cache. This is the single source of update notifications to the author (`issue.sync.reply` / `issue.sync.status`).
|
||||
|
||||
### Filing (async job)
|
||||
|
||||
Filing is an **async job** (`services/jobs/issue_create_service.py` `IssueCreateService`, kind `issue_create`): `process` loads the user, enhances the report, appends a `Reported by **user** via DevPlace` footer, creates the Gitea issue, records the local mapping, notifies the reporter (`Your issue report was filed as #N`), and audits `issue.create`. `cleanup` is a no-op (the issue is permanent; only the job row is swept). `POST /issues/create` enqueues and returns `{uid, status_url}`; frontend `app.issueReporter` (`static/js/IssueReporter.js`) submits the create form, polls `/issues/jobs/{uid}` via `JobPoller`, and redirects to `/issues/{number}`.
|
||||
|
||||
### Routes (`routers/issues/` package, prefix `/issues`)
|
||||
|
||||
- `GET /issues` - Gitea list, `?state=open|closed|all&page=`, admin-style `build_pagination` + `_pagination.html` with a `state=` prefix; renders an empty-state notice when unconfigured/unreachable.
|
||||
- `POST /issues/create` - enqueue (see Filing above).
|
||||
- `GET /issues/jobs/{uid}` - `IssueJobOut`.
|
||||
- `GET /issues/{number}` - `issue_detail.html`: issue body + comments rendered as markdown via `data-render`, decorated with DevPlace authors or a `dev` badge.
|
||||
- `POST /issues/{number}/comment` - synchronous: posts the comment to Gitea attributed to the user, records the author mapping, notifies **all admins**, audits `issue.comment`.
|
||||
- `POST /issues/{number}/status` - admin-only open/closed via Gitea PATCH, audits `issue.status`; non-admins get a `denied` audit + 403.
|
||||
|
||||
Comments and status changes are deliberately synchronous (one fast Gitea call, immediate redirect); only the AI-heavy create is a job. Status-change notifications to the author are NOT emitted by the route - the poller detects the diff and notifies, so the admin (or external developer) closing an issue both flow through one path.
|
||||
|
||||
### Content rendering and mentions (frontend reuse, no new modules)
|
||||
|
||||
The issue body and every comment render through the platform content pipeline like posts/comments - each is a `<div class="...rendered-content" data-render>{{ body|comment.body }}</div>` (`issue_detail.html`), so the globally-instantiated `ContentEnhancer` (`app.content`) runs `contentRenderer.applyTo` (marked -> DOMPurify -> highlight.js -> media/autolink) over Gitea-sourced markdown client-side from `element.textContent`. Gitea content is **NEVER** marked `| safe` into raw HTML; Jinja autoescapes it and the DOMPurify step inside `ContentRenderer` is the XSS control, identical to user posts. The issue authoring textareas (`#issue-description` in the create modal on `issues.html`, and the comment textarea on `issue_detail.html`) carry the `data-mention` attribute, so the same `ContentEnhancer.initMentionInputs()` -> `MentionInput` flow that wires post `@`-mention autocomplete (debounced `GET /profile/search?q=` dropdown) attaches to them with no new code. There is no local issue-edit form (issues are AI-created then commented), so "edit" reuses nothing further. `mention.css` is loaded globally in `base.html`.
|
||||
|
||||
### Attachments (mirror: local store + Gitea assets, `routers/issues/attachments.py`)
|
||||
|
||||
Issues and issue comments accept file attachments, with full add/delete CRUD allowed **only while the issue is open**. Storage is a mirror - the canonical copy is the platform attachment system (`attachments` table, keyed `target_type="issue"`/`target_uid=str(number)` and `target_type="issue_comment"`/`target_uid=str(comment_id)`), and each file is also pushed to the Gitea native asset API so it appears on the tracker. The local row carries a `gitea_asset_id` back-reference (added to the `init_db` attachments ensure-block + born-live in `store_attachment`) so a later delete removes both copies; `attachments.mirror_attachment_to_gitea(uid)` / `remove_gitea_asset(row)` branch on `target_type` to call `GiteaClient.create_issue_asset`/`create_comment_asset` / `delete_issue_asset`/`delete_comment_asset` (all best-effort - the local copy and the user action survive any Gitea failure). Upload reuses the canonical **orphan-then-link** path: `dp-upload` stores to `/uploads/upload`, the form carries `attachment_uids`, and the handler `link_attachments(uids, target_type, target_uid)` then mirrors.
|
||||
|
||||
**Role/state enforcement:** `require_user` blocks guests; add validates each uid is the caller's own unlinked orphan (admins any); delete is owner-or-admin on `attachments.user_uid`; every mutation re-checks the live Gitea `state == "open"` (closed -> 409) and audits denied branches. Attach-at-creation: `IssueForm.attachment_uids` rides into the `issue_create` job, which links + mirrors once the issue number exists; `IssueCommentForm.attachment_uids` links + mirrors inline after the comment is created (synchronous comment path).
|
||||
|
||||
Routes: `GET/POST /issues/{number}/attachments`, `DELETE /issues/{number}/attachments/{uid}`, and the `/issues/{number}/comments/{cid}/attachments[...]` comment variants. Frontend `app.issueAttachments` (`static/js/IssueAttachments.js`) submits the add form (`form[data-issue-attach]` -> POST `attachment_uids` -> reload) and handles per-attachment delete (`[data-attachment-delete]` -> confirm -> DELETE). The shared render partial is `_issue_attachments.html` (gallery + per-item delete gated by `can_modify`).
|
||||
|
||||
Note: for Gitea to accept every file type the instance `app.ini` `[attachment] ALLOWED_TYPES` must be `*/*` (infra, out of app scope); the DevPlace allow-list is the `allowed_file_types` setting, and the local copy is kept even when Gitea rejects a type. Audit events `issue.attachment.add`/`issue.attachment.delete` (category `content`). Devii tools `list_issue_attachments`, `add_issue_attachment`, `delete_issue_attachment` (+ `add_comment_attachment`/`delete_comment_attachment`); the two deletes are in `CONFIRM_REQUIRED`.
|
||||
|
||||
### Schemas, forms, and tools
|
||||
|
||||
Schemas: `IssueItemOut`/`IssueCommentOut`/`IssueDetailOut`/`IssueAttachmentsOut`/`IssuesOut`/`IssueJobOut` (`schemas.py`). Forms: `IssueForm`/`IssueCommentForm`/`IssueAttachmentForm`/`IssueStatusForm` (`models.py`). Devii tools: `list_issues`, `create_issue`, `view_issue`, `comment_issue`, `set_issue_status` (admin), plus the attachment tools above. Docs: the `issues` API group in `docs_api.py`. Footer link in `base.html` under `.site-footer`.
|
||||
@@ -1,127 +0,0 @@
|
||||
This file documents the async job services subsystem (devplacepy/services/jobs/ and its subdirectories deepsearch/, isslop/, seo/) - the standard pattern for running heavy blocking work off the request path. Claude Code loads it automatically whenever a file under this directory is read or edited.
|
||||
|
||||
## Overview: the JobService pattern
|
||||
|
||||
The standard way to run heavy, blocking work off the request path and hand back a result URL, used by every job kind in this subsystem (`zip`, `planning`, `fork`, `seo`, `seo_meta`, `deepsearch`, `isslop`). Reference: admin docs `Architecture -> Async job services` and `Services -> ZipService`.
|
||||
|
||||
- **The DB row is the queue.** `services/jobs/queue.py` (`enqueue`, `get_job`, `touch_job`, `list_jobs`) is pure DB and callable from any worker's handler - enqueue is a fast, non-blocking insert, status reads work from any worker, and only the lock owner processes. All kinds share ONE `jobs` table discriminated by a `kind` column: common lifecycle timestamps, `retry_count`, `last_accessed_at`/`expires_at`, `bytes_in`/`bytes_out`/`item_count` stat columns, plus `payload`/`result` JSON columns where kind-specific fields live.
|
||||
- **`JobService(BaseService)`** runs only in the lock owner. Each `run_once`: reap finished in-flight tasks -> recover orphaned `running` rows (left by a dead owner, since there is only one processor) back to `pending` with bounded `retry_count` -> refill up to `max_concurrent` oldest `pending` jobs (uuid7 sorts FIFO) as `asyncio` tasks -> sweep rows past `expires_at` via the per-kind `cleanup()` hook. In-flight tasks span ticks (kept in an instance map); the loop returns immediately each tick, so keep the interval short (default 2s).
|
||||
- **No atomic claim is needed** (single processor by construction) and **no separate reaper exists** - retention is built into every job service, default 7 days, admin-configurable; downloading/reading a result extends `expires_at` via `touch_job`.
|
||||
- **Add a kind:** subclass `JobService`, set `kind`, implement `async process(self, job) -> dict` (return the `result` dict incl. stat keys) and `cleanup(self, job)`; register the service instance in `main.py`; add enqueue endpoints that own authz, a status route, a download/report route, a Devii tool, and docs. **A new `JobService` needs a server restart to go live.**
|
||||
- **Permanent vs expiring artifacts (load-bearing distinction - decide this per kind).** Most kinds produce a DISPOSABLE artifact that expires with the retention sweep: `ZipService`, `SeoService`, and `DeepsearchService` all delete their real output (archive / report+screenshots / vector collection+report) inside `cleanup()`. Two kinds are different: `ForkService`'s artifact is a **permanent project**, and the AI Usage Analyzer's (`isslop`) artifacts (analysis, events, file/image results, report, badge) are **permanent public capability URLs** - both make `cleanup()` a no-op on the real artifact and let the retention sweep remove only the `jobs` tracking row. Get this decision right for any new kind: if the output should outlive the job the way a fork or an isslop report does, do not wire `cleanup()` to delete it.
|
||||
|
||||
## ZipService (kind `zip`)
|
||||
|
||||
- `process` materializes a project subtree via `project_files.export_to_dir` (staging under `config.DATA_DIR/zip_staging/{uid}`), compresses it in a **subprocess** (`python -m devplacepy.services.jobs.zip_worker`, stdlib-only, prints stats JSON incl. crc32), names the output `{crc32}.{slug}.zip` under `config.DATA_DIR/zips/{uid[-2:]}/{uid[-4:-2]}/` (sharded on the random tail of the uuid7 via `attachments._directory_for`, NOT its time-ordered head), and removes staging.
|
||||
- Runtime artifacts live in `DATA_DIR` (`data/` by default), OUTSIDE the package and NOT under `/static`; the download is served by the `/zips/{uid}/download` route via `FileResponse`, not the static mount.
|
||||
- Enqueue endpoints own authz: `POST /projects/{slug}/zip`, `POST /projects/{slug}/files/zip?path=`.
|
||||
- `GET /zips/{uid}` (status `ZipJobOut`) and `GET /zips/{uid}/download` (FileResponse, extends expiry via `touch_job`) are **capability URLs** scoped only by the unguessable uuid7, not by owner - the owner is stored for attribution, not access control, matching publicly-viewable projects.
|
||||
- Frontend `app.zipDownloader` (`static/js/ZipDownloader.js`) auto-wires any `data-zip-download` element plus the files context menu: POST -> poll `/zips/{uid}` via the shared `JobPoller` -> trigger download.
|
||||
- Devii tools `zip_project`/`zip_status`; CLI `devplace zips prune|clear`.
|
||||
- Disposable: `cleanup()` deletes the archive and the retention sweep prunes both the file and the `jobs` row.
|
||||
|
||||
## Planning report generator (kind `planning`, `services/jobs/planning_service.py`)
|
||||
|
||||
`PlanningReportService` is **admin-only** and builds a complete, phased markdown implementation document from a selectable set of open Gitea tickets, intended to be handed straight to a coding agent for one-shot execution, off the request path via the same async-job pattern as zip.
|
||||
|
||||
- `process` collects open tickets via `services/gitea/planning.py` `collect_open_issues(client, limit=MAX_ISSUES)` (the paginated `list_issues(state="open", limit=50)` loop, cap 50; reused by the admin page too), narrows them to the **selected ticket numbers** carried in the job payload (`{"numbers": [int, ...]}`, preserving selection order - an empty/absent list keeps all open, so the no-arg Devii action and any legacy enqueue stay backward compatible), and builds the markdown via `generate_plan(issues, config)` (an AI-or-fallback helper mirroring `enhance.py`).
|
||||
- **AI path:** passes each ticket's FULL, verbatim description (`_body`, capped only at the `BODY_MAX=40000` per-ticket safety limit, not the old 600-char excerpt) to the internal gateway via `stealth.stealth_async_client` with a high output budget (`MAX_TOKENS=32000`, `PLAN_MAX=600000` final cap, `PLANNING_TIMEOUT_SECONDS=600` client timeout) and a `SYSTEM_PROMPT` that demands a self-contained document: a `## Execution Order` list, then `## Phase K: <name>` headings, and under each phase a `### #N <title>` subsection per ticket carrying labelled **Original ticket** (verbatim blockquote), **Goal**, **Dependencies**, **Affected areas / files**, **Implementation steps**, **Acceptance criteria**, and **Risks / open questions** blocks - preserving every detail with nothing summarised away.
|
||||
- **Because an LLM can still summarise or truncate, the AI document is never trusted to be complete on its own:** `generate_plan` always appends a deterministic `# Appendix: Source Tickets (verbatim)` section built straight from the issue dicts by `planning.py` `verbatim_tickets(issues)` (per ticket `## #N <title>`, labels, the `html_url` source link, and the full body as a blockquote), so every covered ticket's full text is guaranteed present inline AND in the appendix - the document is genuinely self-contained.
|
||||
- Fail-soft `_fallback` (when `ai_enhance` is off or any `httpx.HTTPError`/`ValueError`/`KeyError`/`IndexError` occurs) groups by primary label then ticket number, emitting the same `## Execution Order` + `## Phase K` shape with each ticket's full description reproduced verbatim (already self-contained, so no extra appendix).
|
||||
- Writes the markdown to `config.PLANNING_REPORTS_DIR/_directory_for(uid)/{crc32}.{slug}.md` (sharded on the uuid7 random tail, crc32 over the markdown bytes), and returns `{download_url, local_path, final_name, markdown, ai_used, item_count, bytes_out}`. The `markdown` string is carried in the result so the status route can hand it to the renderer without re-reading disk. `cleanup` unlinks the file (retention prunes only the artifact + job row).
|
||||
- Audit `issue.planning.request` (enqueue) and `issue.planning.generate` (success/failure) via `record`/`record_system` with an `audit.job(uid)` link.
|
||||
- Routes (all `require_admin`): `POST /issues/planning` (enqueue, `503` when Gitea unconfigured, `PlanningForm` with a comma-separated `numbers` field parsed to `list[int]` and stored in the payload, returns `{uid, status_url}`), `GET /issues/planning/{uid}` (`PlanningJobOut`: status, `markdown`, `download_url`, `issue_count`, `ai_used`), `GET /issues/planning/{uid}/download` (`FileResponse`, `text/markdown`, traversal-guarded against `PLANNING_REPORTS_DIR`, extends retention via `touch_job`). The `/issues/{number}` detail route uses the `{number:int}` path converter so `/issues/planning` is never captured by it.
|
||||
- Admin entry point: `GET /admin/issues/planning` (`routers/admin/issues.py`, `admin_issues_planning.html`) fetches the open tickets server-side via `collect_open_issues` (wrapped so a Gitea/network failure renders an empty state, never a 500) and renders a **selectable checkbox list** (all checked by default, a select-all/none master, and a live selected count) as the step in between, plus the **Generate planning** button, the `JobPoller` status panel, the `<dp-content>` render target, and a **Download** anchor; the issues listing shows an admin-only **Generate planning** link (`viewer_is_admin` in `issues_page`).
|
||||
- Frontend driver `static/js/PlanningGenerator.js` (`app.planningGenerator`): tracks the checkbox selection (master toggle, count, disables Generate at zero selected), sends the chosen numbers as the `numbers` form field, POST -> `JobPoller.run` (widened to `intervalMs:2000, maxAttempts:400` = ~800s so it outlives the longer detailed generation) -> replace the `<dp-content>` with a fresh one holding `status.markdown` so it re-renders, set the download href.
|
||||
- Devii tool `planning_report_generate` (`requires_admin=True`). The renderer's built-in copy button (the `dp-content` **Copy** button styled in `markdown.css`, identical to the per-code-block copy button) satisfies the copy-source requirement and is opt-out via the `no-copy` boolean attribute on `<dp-content>`.
|
||||
|
||||
## Project fork - ForkService (kind `fork`, `services/jobs/fork_service.py`)
|
||||
|
||||
Forking copies a source project into a brand-new project owned by the forking user, off the request path via the same async-job pattern as the zip flow.
|
||||
|
||||
- **Enqueue:** `POST /projects/{slug}/fork` (`routers/projects/index.py`) - `require_user` then `can_view_project(source, user)` (any logged-in user may fork any project they can view: public projects and their own private ones). Body is `ForkForm{title}` (the destination project name). It enqueues a `fork` job (`{source_project_uid, title, forked_by_uid}`, owner `("user", uid)`) and returns `{uid, status_url}`. No work happens on the request path.
|
||||
- **`process`** looks up the source project row and the forking user row (raises -> job `failed` if either is missing), creates the destination project with `content.create_content_item("projects", "project", user, fields, ...)` (so slug/XP/logging match a normal create; metadata - `description`, `project_type`, `platforms`, `status`, dates, `is_private` - is copied from the source, `read_only` reset to 0), copies the whole virtual FS off-thread (`export_to_dir(source, "", staging)` -> `import_from_dir(new_uid, staging, user, skip_names=set())`, an **exact** copy including binaries, staging under `config.DATA_DIR/fork_staging/{uid}`), then records the relation with `database.record_fork(source_uid, new_uid, forked_by_uid)`. Result: `{project_uid, project_url, source_project_uid, item_count}`.
|
||||
- **Rollback:** any failure after the project is created triggers `_rollback(new_uid)` (`project_files.delete_all_project_files` + `delete_fork_relations` + delete the `projects` row) before re-raising, so a failed fork leaves no orphan project.
|
||||
- **Cleanup is a no-op on the project.** Unlike zip's disposable archive, a fork's artifact is a **permanent project** that must outlive the job. `cleanup()` only removes any leftover staging dir; the retention sweep deletes the job tracking row, never the forked project. `devplace forks prune|clear` likewise deletes job rows only.
|
||||
- **Relation model:** the `project_forks` table (`uid`, `source_project_uid`, `forked_project_uid`, `forked_by_uid`, `created_at`) records direction explicitly (source -> forked), indexed on both project columns. Helpers in `database.py`: `record_fork`, `get_fork_parent(forked_uid)` (the source project row, for the "Forked from X" link on the project detail page), `count_forks(source_uid)`, and `delete_fork_relations(project_uid)` (called on project delete in `content.delete_content_item`, and in fork rollback).
|
||||
- **Frontend:** `app.projectForker` (`static/js/ProjectForker.js`) wires `data-fork-project` (a Fork button on the project detail page, visible to any logged-in user): prompt for a name via `app.dialog.prompt` -> `Http.sendForm` POST -> `JobPoller.run("/forks/{uid}", ...)` -> on `done` redirect to `project_url`. The forked project's detail page shows a "Forked from X" link (`database.get_fork_parent`).
|
||||
- **Status route** `GET /forks/{uid}` (`routers/forks.py`, `ForkJobOut`) exposes `project_uid`/`project_url`/`source_project_uid` only once `status == done`. Devii tools `fork_project`/`fork_status`; docs `projects-fork`/`forks-status`.
|
||||
|
||||
## SEO Diagnostics tool - SeoService (kind `seo`, `services/jobs/seo/`, `routers/tools/`)
|
||||
|
||||
The public **Tools -> SEO Diagnostics** auditor crawls a URL or sitemap with a headless browser and runs a broad battery of SEO checks, on the **same async-job pattern as zip/fork** plus a live websocket. It is **public (guests included)**; abuse is bounded by the per-IP POST rate limit, a per-owner one-active-job cap, a page cap, and the shared SSRF guard.
|
||||
|
||||
- **Surface:** a collapsible **Tools** dropdown in `base.html` (desktop center nav + a mobile section, visible to everyone) toggled by `MobileNav.initToolsDropdown`. `GET /tools` lists tools; `GET /tools/seo` is the auditor page (`static/js/SeoDiagnostics.js` -> `app.seoDiagnostics`, instantiated page-side in the template, not in `Application.js`).
|
||||
- **Enqueue:** `POST /tools/seo/run` (`routers/tools/seo.py`, body `SeoRunForm{url, mode: url|sitemap, max_pages 1-50}`). Owner is `("user", uid)` or `("guest", X-Real-IP)`. It rejects with `429` if the owner already has a pending/running `seo` job, then enqueues `{url, mode, max_pages, allow_private:False}` and returns `{uid, status_url, ws_url}`.
|
||||
- **`process`** writes the payload to `config.SEO_REPORTS_DIR/{uid}/payload.json`, launches `python -m devplacepy.services.jobs.seo.worker <payload_json> <output_dir>` via `create_subprocess_exec` (high `limit=` so big lines never overflow the StreamReader), reads **NDJSON frames from stdout** line by line (stage/target/progress/page/site_checks/report_ready), forwards each into the in-process **`ProgressHub`** (`services/jobs/seo/progress.py`, uid -> set of `asyncio.Queue`), and on completion loads `output_dir/report.json` as the job result. `cleanup()` clears the hub buffer and removes the report dir.
|
||||
- **Worker** (`worker.py`, subprocess): `crawler.crawl_target` resolves the target (single URL, or sitemap `<loc>` URLs capped at `max_pages`) and fetches `robots.txt`/`sitemap.xml`/`llms.txt` with `httpx`. The audited target host is guarded once with `net_guard.guard_public_url`; candidate URLs **sharing that host are pre-approved** (no redundant per-URL `getaddrinfo` - a transient DNS failure or a self-hosted server resolving its own domain must not blank the whole crawl), and only cross-host sitemap entries are re-guarded. **In sitemap mode the crawler never falls back to auditing the sitemap document itself**: if no page URLs survive it raises a clear error (a stray `or [target]` fallback previously rendered the sitemap XML as one 56k-node "page" with no title/H1). For each page it launches one Playwright navigation: a single `page.evaluate(EXTRACT_SCRIPT)` returns the whole DOM contract (title/metas/canonical/headings/images/links/jsonld/og/twitter/semantic/mixed-content/word-count), an injected `PerformanceObserver` (`add_init_script(INIT_SCRIPT)`) captures LCP/CLS, navigation timing gives TTFB/FCP/transfer/protocol, a mobile-viewport pass measures overflow/tap-targets, a screenshot is saved, and a raw `httpx` GET supplies the SSR HTML for the rendered-vs-server parity check.
|
||||
- **Check registry** (`checks/`): `base.py` defines the `Check`/`PageContext`/`SiteContext` dataclasses and the `@page_check`/`@site_check` decorators (collected into `PAGE_CHECKS`/`SITE_CHECKS`); one module per category (`crawl`, `meta`, `headings`, `links`, `structured_data`, `social`, `performance`, `mobile_a11y`, `security`, `ai_readiness`, `crosspage`). `registry.run_page_checks`/`run_site_checks` run them defensively (one failing check never aborts a page), and `compute_score` produces a severity-weighted overall score + grade and per-category subscores. **To add a check:** write a function decorated `@page_check`/`@site_check` in the right category module and import that module in `registry.py`.
|
||||
- **Live progress WS:** `WS /tools/seo/{uid}/ws` is served **only by the service-lock owner** (closes `4013` for a fast retry on a non-owner worker, like `/devii/ws`); it replays `hub.snapshot(uid)` then streams `hub.subscribe(uid)`. The frontend `SeoProgressSocket` mirrors the `DeviiSocket` 4013/reconnect pattern. **The `done` frame carries the full report inline** (and the router's late-join terminal branch reads it from `job.result`); the client renders from `frame.report` directly. This is load-bearing: `service.process` publishes `done` from inside `process()`, BEFORE the JobService base persists the result on the next reap tick (~2s later), so a client that fetched `/tools/seo/{uid}/report` on the `done` signal would race the DB write and get an empty (all-zero) report. **Do not "simplify" this back into a fetch-on-done.** After publishing `done`, `process()` calls `hub.clear(uid)` to bound the in-memory buffer (late reconnects fall back to the DB-backed terminal branch).
|
||||
- **Status/report routes:** `GET /tools/seo/{uid}` (`SeoJobOut`), `GET /tools/seo/{uid}/report` (`respond(..., SeoReportOut)`, HTML or JSON), `GET /tools/seo/{uid}/screenshot/{n}` (FileResponse from `SEO_REPORTS_DIR`, path-guarded). All are **capability URLs** scoped by the unguessable uuid7. The full report is written to `config.SEO_REPORTS_DIR/{uid}/report.json` (NOT inlined on a stdout line - avoids the StreamReader limit). Devii tools `seo_diagnostics`/`seo_status`/`seo_report` (public); docs `tools-seo-*`; CLI `devplace seo prune|clear`; audit `seo.run.request|complete|failed` (category `tools`).
|
||||
- **SSRF guard is shared:** `devplacepy/net_guard.py` (`guard_public_url`, `is_blocked_address`, `effective_address`) was extracted from the Devii fetch controller, which now imports it; the crawler reuses it. **`playwright` is a core dependency** (Chromium installed by `make install` / the Docker image).
|
||||
- Disposable: `cleanup()` removes the report dir; retention prunes both the artifacts and the `jobs` row.
|
||||
- **Production nginx needs a dedicated WS location.** In `nginx/nginx.conf.template` the catch-all `location /` sets `Connection ""` (no upgrade) and a 60s timeout, so any websocket route that falls through to it fails the handshake (browser: `WebSocket connection failed`, no close code). The progress socket has its own `location ~ ^/tools/seo/[^/]+/ws$` block forwarding `Upgrade`/`Connection` with a 1h timeout, mirroring `/devii/ws`. **Any new websocket path must add its own upgrade `location` above `location /`** (see docs `Production -> nginx`).
|
||||
|
||||
## SEO metadata service - SeoMetaService (kind `seo_meta`, `services/jobs/seo_meta_service.py`, `services/seo_meta.py`, `seo_meta_text.py`)
|
||||
|
||||
`SeoMetaService` generates a clean, SEO-optimized title/description/keywords for every published content item (types `post`, `project`, `gist`, `news`, `issue`) off the request path and **meters its own AI spend**. It is a distinct concern from the public **Tools -> SEO Diagnostics** auditor (kind `seo`) - do NOT conflate the two kinds; they share only the `jobs` queue table. It is the **constructive counterpart** to the diagnostics tool: diagnostics audits a URL, this one populates the on-page metadata. It is a `JobService` like `ForkService`: the artifact (the `seo_metadata` row) is **permanent**, so `cleanup()` is a no-op and the retention sweep removes only the job tracking row.
|
||||
|
||||
- **Tables.** `seo_metadata` is polymorphic and soft-deletable (in `SOFT_DELETE_TABLES`, born-live `deleted_at`/`deleted_by`): `uid, target_type, target_uid, seo_title, seo_description, seo_keywords, status (ready|pending|failed), source (ai|plain), generated_at, created_at, updated_at`, keyed UNIQUE on `(target_type, target_uid)`. `init_db()` ensures every column, the UNIQUE `idx_seo_metadata_target` and the live `idx_seo_metadata_status (status, deleted_at)` index. `seo_usage` is a single-row config-like usage table (NOT soft-delete) mirroring `news_usage`, keyed `SEO_USAGE_KEY="seo_meta"`. Helpers in `database.py`: `get_seo_metadata`/`get_seo_metadata_batch`/`has_fresh_seo_metadata`/`upsert_seo_metadata`/`mark_seo_metadata_stale` (every read filters `deleted_at IS NULL`; `get_seo_metadata` returns only `status="ready"` live rows) and `add_seo_usage`/`get_seo_usage`.
|
||||
- **Choke helper.** `services/seo_meta.py` `schedule_seo_meta(target_type, uid, regenerate=False)` and `schedule_seo_meta_for_table(table, uid, ...)` are import-cycle-free (only `database` + `queue`). They no-op for unknown types, missing uid, or (without `regenerate`) when a fresh `ready` row exists (`database.has_fresh_seo_metadata`); `regenerate=True` marks the row stale first (`database.mark_seo_metadata_stale`); both skip a target with an existing pending/running `seo_meta` job; otherwise `queue.enqueue("seo_meta", {target_type, target_uid}, "system", "seo_meta")`. Hooked at `content.create_content_item` (create, no-op guard) and `content.edit_content_item` (regenerate), the news publish sites in `services/news.py` (`status=="published"` only; existing-row update path uses `regenerate=True`), and `IssueCreateService` after the Gitea ticket is recorded. Because the work is async via the queue (NOT `run_in_executor`), the helper only enqueues.
|
||||
- **`process`** loads the target row (posts/projects/gists/news via `get_table`, issues via `gitea.store.get_ticket`), builds grounding via `services/ai_context.build_context` (fail-soft for news/empty `user_uid`), and calls the gateway **off-thread** (`asyncio.to_thread(correction.gateway_complete, internal_gateway_key(), system, source_text, timeout)` - the synchronous gateway call posts to the in-process gateway on localhost, so it MUST run via `to_thread` or it self-deadlocks the single worker - the same lesson as `correction.py`'s sync mode). It demands strict JSON `{seo_title, seo_description, seo_keywords}`, parses fail-soft, re-clamps every field server-side via `seo_meta_text.clamp_generated`, and falls back to `seo_meta_text.plain_seo_defaults` (status `failed`, source `plain`) on any failure - **the fields are never empty**. Usage accumulates via `correction.new_usage_totals` and flushes once with `database.add_seo_usage(totals)` when `calls>0` (the single-row `seo_usage` table, mirroring `news_usage`). It emits an audit `seo.meta.generate`/`seo.meta.failed` (`record_system`, category `tools`) and `upsert_seo_metadata(...)`.
|
||||
- **Backfill.** `run_once` calls `super().run_once()` then a bounded backfill sweep (gated by `seo_meta_backfill_enabled`, `seo_meta_backfill_batch` per type per tick) over published content lacking a fresh `ready` row, so pre-existing items get metadata with no one-shot migration.
|
||||
- **Admin surface.** `collect_metrics()` merges the `JobService` job-pipeline stats with `usage_metric_cards(get_seo_usage())`, so the **SEO Metadata** card on `/admin/services` shows both the live task pipeline and the AI cost/averages; the existing `admin.services.{name}` pub/sub topic + `live_view_relay` row pushes it live with NO new VIEWS row. A standard-paginated task list reads `queue.list_jobs(kind="seo_meta")` with `database.build_pagination` + `_pagination.html` when a dedicated page is desired.
|
||||
- **Clamps (single source of truth, `seo_meta_text.py`):** `seo_title` hard cap 60 (word-boundary, single hyphen, keyword front-loaded); `seo_description` hard cap 160 (word-boundary via `seo.truncate`, key message in the first 120 chars); `seo_keywords` 5-8 distinct lowercase comma-joined terms (the `<meta keywords>` tag is dead for Google but the feature mandates it - emit a SHORT honest list, never stuffed). `plain_text_from_markdown` reuses `rendering._render_content` + `utils.strip_html` so markdown (and em-dash) never leaks.
|
||||
- **SEO consumption fix (`seo.py` `base_seo_context`).** The meta description is now markdown-stripped (`plain_markdown`/`plain_text_from_markdown`, fixing the prior raw-markdown leak), `meta_keywords` is emitted (a safe plain string, NOT a Jinja-global name), and a `seo_target=(target_type, target_uid)` param makes it consume the ready `seo_metadata` row when present, else `plain_seo_defaults`. `base_seo_context`'s new `keywords`/`seo_target` params are OPTIONAL with safe defaults so existing callers are unaffected; the five detail routers (posts/projects/gists/news/issues) pass `seo_target`. `og_title`/`twitter:title` use the bare `seo_title` (drops the redundant " - DevPlace" suffix in social cards); `base.html` adds `<meta name="keywords">`, `og:image:width/height/alt` and `twitter:image:alt`. The per-type JSON-LD (`discussion_forum_posting`, `software_application_schema`, `news_article_schema`, `software_source_code_schema`) route their text/description through `plain_markdown`.
|
||||
- **Fan-out.** Schema `SeoMetaOut`; read route `GET /tools/seo-meta/{target_type}/{target_uid}` (`routers/tools/index.py`, public, JSON) returning the ready row or a plain default with status `pending`; Devii action `seo_meta_status` (public, read-only) + docs `tools-seo-meta-status`; CLI `devplace seo-meta prune|clear` (job rows only; the metadata persists); events `seo.meta.generate|failed`. Registered in `main.py` alongside `SeoService`. **A new `JobService` needs a server restart to go live.**
|
||||
|
||||
## DeepSearch tool - DeepsearchService (kind `deepsearch`, `services/jobs/deepsearch/`, `services/deepsearch/`, `routers/tools/deepsearch.py`)
|
||||
|
||||
The public **Tools -> DeepSearch** researcher is a multi-agent deep web researcher built on the **same async-job + ProgressHub + 4013-WS pattern as the SEO tool**, plus a per-session vector store and a grounded RAG chat. It is **public (guests included)**; abuse is bounded by the per-IP POST rate limit, a per-owner one-active-job cap, a page cap (1-30), depth cap (1-4), and the shared SSRF guard. Reuse the SEO tool as the template for any new Tools async job.
|
||||
|
||||
- **Owner helper is shared:** `routers/tools/_shared.py` `owner_for(request)` returns `("user", uid)` or `("guest", X-Real-IP)`; both `seo.py` and `deepsearch.py` import it (do not re-inline the owner derivation).
|
||||
- **Enqueue:** `POST /tools/deepsearch/run` (body `DeepsearchRunForm{query, depth 1-4, max_pages 1-30}`). It rejects with `429` if the owner already has a pending/running `deepsearch` job. It resolves the **logged-in user's `users.api_key`** (guests use `database.internal_gateway_key()`) into the job payload for per-user embedding/LLM spend attribution, generates the uid up front, writes a `deepsearch_sessions` row (`create_deepsearch_session`), enqueues the job carrying `{query, depth, max_pages, api_key, collection}`, and returns `{uid, status_url, ws_url}`. The enqueue uses a local `_enqueue` (not `queue.enqueue`) so the session uid and the job uid match.
|
||||
- **`process`** writes `control.json` (state `running`) + `payload.json` (augmented with the cross-session `cached_hashes`) under `config.DEEPSEARCH_DIR/{uid}`, launches `python -m devplacepy.services.jobs.deepsearch.worker <payload_json> <output_dir>` via `create_subprocess_exec` (high `limit=`), pumps **NDJSON stdout frames** into the in-process `ProgressHub` (`progress.py`), and on completion loads `output_dir/report.json`, persists the URL cache (`upsert_deepsearch_url_cache`), and updates the session row. `cleanup()` **drops the ChromaDB collection** (`VectorStore.drop`) and removes the session dir. Disposable: the collection + report dir are both deleted, unlike Fork/isslop.
|
||||
- **Worker pipeline** (`worker.py`, stdlib + httpx + playwright, importable subprocess): `enhance.plan_queries` (gateway -> JSON sub-queries, deterministic fallback) -> `crawl.search_queries` (rsearch via standalone httpx, never `PlatformClient`; per-query result buckets are **round-robin interleaved** so every planned angle contributes pages, never just the first query) -> `crawl.crawl` (batches of `CRAWL_CONCURRENCY` concurrent httpx fetches then playwright render fallback, `guard_public_url` on the URL and every redirect, content-hash + URL-hash dedup; **`depth` follows in-page links**: after each level the links of every crawled page are scored by query-token overlap via `extract.relevant_links` and the top `LINKS_PER_PAGE` unseen ones form the next level, `depth=1` disables following) -> `chunking.chunk_text` -> `embeddings.embed_texts` (gateway, **local hashing fallback** when unavailable) -> `store.VectorStore.add` (Chroma) -> `orchestrate.orchestrate` (retrieval-grounded agents, see below). The worker writes `report.json` and `url_cache.json` and emits a `report_ready` frame carrying `synthesis`.
|
||||
- **Search-provided content is a first-class source (`crawl.py`, the second junk-report fix).** `search_queries` calls rsearch with `content=true`, so each candidate carries the search engine's own readable `content`/`description` extract. This matters because the top sources for many questions are **bot-hostile** (X/Twitter, YouTube, Reddit, Facebook, Instagram, LinkedIn, TikTok - `HOSTILE_DOMAINS`): a headless fetch of those hits a login/consent wall ("Before you continue to YouTube", "Sign in to X") and yields near-zero text, which is why a 12-source run used to collapse to ~14 chunks. Now `crawl._resolve_candidate` **skips the fetch entirely for a hostile domain and uses the rsearch snippet** (`_snippet_page`, `source="search"`, `SNIPPET_MIN_CHARS` floor), and for every other domain it fetches normally but keeps the rsearch snippet as a **floor** (uses whichever of crawl-text vs snippet is longer), so a walled or thin page still contributes its real content instead of being dropped. This alone took a query from "cannot be answered" to a correct cited answer (14 -> 61 chunks, diversity 0.333 -> 0.75). **Never revert `content=true` and never send a headless render at a `HOSTILE_DOMAINS` host.**
|
||||
- **Content extraction (`extract.py`, stdlib only):** `extract_html(raw, base_url)` is a readability-grade `HTMLParser` extractor used by both the httpx and playwright fetch paths (the old naive regex tag-stripper produced nav/cookie-banner boilerplate as "content" - the historic root cause of junk reports). It skips `script/style/nav/header/footer/aside/form` and ARIA `role=navigation|banner|contentinfo|...` regions, prefers `<article>`/`<main>` when they carry at least `MIN_CONTENT_TOTAL` chars, drops link-dense blocks (`MAX_LINK_DENSITY`, menus) and sub-`MIN_BLOCK_CHARS` fragments, unescapes entities, and emits real paragraphs joined by blank lines - which also makes `chunking.chunk_text`'s paragraph split actually fire (the flattened text used to be sliced mid-sentence). It also returns the page's `(url, anchor_text)` links (absolute, deduped, nav links excluded) for depth crawling; `relevant_links(links, query, limit)` scores them by query-token overlap and filters non-document extensions.
|
||||
- **No "gaps"/critic agent (removed - do not reintroduce).** DeepSearch used to run a fourth "critic" agent that produced an "Open gaps" list. It was removed end-to-end (orchestrate, worker report, `DeepsearchSessionOut`, router context, session template, markdown/HTML export, `DeepsearchTool.js` agent labels, CSS) because it routinely emitted misleading, self-contradictory gaps on correctly-cited reports (the historic cause was that the critic was fed a truncated, retrieval-ordered source slice that dropped cited source numbers out of its window, so it fabricated "citation [n] is not in the report / no evidence provided"). **Do NOT reintroduce a `gaps` field or a critic agent.** The `_numbered_source_digest(pages)` helper survives and is used by the **linker** - a compact per-source `[n] title (url)\nexcerpt` block for EVERY page where the header line is always emitted even when the excerpt is trimmed, so all source numbers `1..N` are guaranteed present (never pass a raw `context[:N]` slice to an agent that reasons about source numbers). Regression: `tests/unit/services/jobs/deepsearch/orchestrate.py::test_linker_receives_full_source_list` / `::test_numbered_source_digest_keeps_every_source_number_under_cap`.
|
||||
- **Orchestration (`orchestrate.py`) is retrieval-grounded and markdown-first.** The worker indexes BEFORE analysis and passes the `VectorStore` + planned queries to `orchestrate`, which embeds the question and each sub-query and pulls `hybrid_search` top chunks (round-robin merged, up to `CONTEXT_CHUNKS_MAX`), building the context from the RETRIEVED passages grouped per source - the source numbers `[n]` align with the report's `sources` list (page order), so inline citations, finding citations, and the rendered numbered source list agree. Page-head excerpts are only the fallback when retrieval is empty. Synthesis is **two-step to avoid the markdown-inside-JSON trap**: the summarizer writes a plain markdown report (`REPORT_MAX_TOKENS`, retried once if empty), then a separate `extractor` agent returns the findings JSON (retried once, tolerant `_parse_json` handles code fences and trailing garbage); the linker (confidence) failure is caught and never discards the report. The three agents in the pipeline today are summarizer, extractor, and linker - there is no fourth "critic" stage. Only a failed/empty report falls back to `_heuristic`, which stamps `synthesis="heuristic"` and emits a `status:"failed"` agent frame - the degradation is VISIBLE: `report.synthesis` flows through `DeepsearchSessionOut.synthesis`, the session template renders a "Degraded report" banner (`.ds-degraded`), and the markdown export carries the same note. A successful run stamps `synthesis="agents"`. **Never re-inline synthesis into a single JSON blob and never let a synthesis failure ship silently.**
|
||||
- **PDF ingestion (`crawl.py` `fetch_page` + `pdf.py`):** a crawled candidate is treated as a PDF when its `content-type` is `application/pdf`/`application/x-pdf`, its URL path ends in `.pdf`, or its first bytes match the `%PDF-` magic (`pdf.is_pdf`). `fetch_page` streams the body and caps it at `MAX_PDF_BYTES` (15 MB); for a PDF it calls `pdf.extract_pdf_text`, which writes the raw binary to a `tempfile` temp location (cleaned up via `Path.unlink(missing_ok=True)` in `finally`), parses it with `pypdf` (`PdfReader`, capped at `MAX_PDF_PAGES` = 50, title pulled from metadata), and normalizes whitespace. The resulting `CrawledPage` carries `source="pdf"`; PDFs skip the Playwright fallback. Everything downstream is source-agnostic (chunking/embedding/orchestration read `page.text`/`page.source` unchanged), so no other module changes. New unpinned dep `pypdf` (pure-python, no system deps).
|
||||
- **Pause/resume/cancel:** `POST /tools/deepsearch/{uid}/{pause|resume|cancel}` (owner-gated) rewrite `control.json`; the worker's `should_stop` callback polls it between source fetches (paused = sleep-loop, cancelled = stop). State lives in a file, not the job row, so the running-in-a-subprocess worker can read it without a DB round-trip.
|
||||
- **Vector store (`services/deepsearch/store.py`):** `VectorStore` wraps `chromadb.PersistentClient(path=config.DEEPSEARCH_CHROMA_DIR)`, one collection per session (`ds_<uid>`). `Chunk` is the dataclass. `hybrid_search` blends cosine vector similarity with a BM25 keyword score (weights `HYBRID_VECTOR_WEIGHT`/`HYBRID_KEYWORD_WEIGHT`) over the candidate set, with optional metadata `where` filters. `embeddings.py` `embed_texts` calls the gateway embeddings endpoint and **falls back to a deterministic local hashing vector** on any failure (so the tool degrades, never breaks).
|
||||
- **RAG chat (`services/deepsearch/chat.py` + `WS /tools/deepsearch/{uid}/chat`):** a dedicated lightweight loop (NOT the Devii hub), served **only by the service-lock owner** (closes `4013` for fast retry). Answers are grounded ONLY in the session collection via `hybrid_search`, cited inline, rendered client-side via `dp-content`. Turns persist to `deepsearch_messages` and audit `deepsearch.chat`. Frontend component `<dp-deepsearch-chat>` (`static/js/components/AppDeepsearchChat.js`) clones `AppDocsChat`'s framing but uses its own WebSocket to the chat path.
|
||||
- **Status/report/export routes:** `GET /tools/deepsearch/{uid}` (`DeepsearchJobOut`), `GET /tools/deepsearch/{uid}/session` (`respond(..., DeepsearchSessionOut)`, HTML or JSON), `GET /tools/deepsearch/{uid}/export.{md,json,pdf}` (`services/deepsearch/export.py`; PDF via weasyprint). All are **capability URLs** scoped by the unguessable uuid7. **Viewer-flag discipline:** the session schema/context use `viewer_is_admin`/`viewer_owns` (never `is_admin`/`owns`) so a `respond()` context key never shadows a Jinja global (the same class of issue as the issues `/{number}` route). `tests/api/tools/deepsearch/session.py` guards the HTML render.
|
||||
- **Completion race (load-bearing read-path fix).** The worker writes `report.json` to disk and `service.process` publishes the `done` frame **from inside `process()`**, but the `JobService` framework only commits `jobs.result`/`status=DONE` afterwards, in `_reap()` -> `_finish_done()` on a later tick. The frontend navigates to the session page the instant it receives `done`, so a read that keyed only off `jobs.status == DONE` returned an EMPTY report (`None` score, 0 sources) until a manual refresh. Fix: `_report_for(uid, job)` returns `job.result.report` when the job is `DONE` and non-empty, else falls back to the on-disk `report.json` (`_report_from_disk`, `DEEPSEARCH_DIR/{uid}/report.json`) - which exists before the `done` frame is ever sent - and returns `{}` only for a `FAILED` job or a genuinely still-running job with no report on disk. `_session_context` derives `done`/`status` from `bool(report)` (not raw job status), and the chat WS gate accepts `session.status == "done"` (set inside `process()` before the publish) as ready. `_export_report` reuses the same fallback. Regression: `tests/api/tools/deepsearch/session.py::test_session_reads_disk_report_before_result_commit`. **Any new read of a job result that a client reaches immediately after a `done`/`session_url` frame must use this same on-disk fallback, never bare `jobs.status`.**
|
||||
- **Clickable inline citations (`services/deepsearch/citations.py`).** The report/findings carry `[n]` markers (and the model sometimes emits `[3][9][1-2]`); the `link_citations(html, source_count)` template global (registered in `templating.py`) rewrites each `[n]` and each `[a-b]` range into `<a class="ds-cite" href="#ds-source-n">[n]</a>` anchors that jump to the numbered `<li id="ds-source-n">` in the Sources list (source numbering is page order, matching the `[n]` the summarizer was given). It splits out `<a>`/`<code>`/`<pre>` regions first so markers inside links/code are left alone, expands ranges to individual links, and drops out-of-range numbers (no broken anchors). The session template nests it over the server render: `{{ link_citations(render_content(summary), sources|length) }}` and `{{ link_citations(finding.detail|e, sources|length) }}`, plus a per-finding `.ds-finding-cites` chip row from `finding.citations`. `.ds-cite`/`.ds-sources li:target` styling lives in `deepsearch.css`. The report prompt asks for one number per bracket (never a range) so output is consistent, but the linkifier handles ranges regardless. Regression: `tests/unit/services/deepsearch/citations.py`.
|
||||
- **Tables (`deepsearch_sessions`, `deepsearch_messages` soft-deletable + in `SOFT_DELETE_TABLES`; `deepsearch_url_cache` GC-only):** columns are ensured in `init_db()` (every queried column) with indexes. Every insert writes `deleted_at:None/deleted_by:None`; every read filters `deleted_at IS NULL`.
|
||||
- **Frontend** (do not hand-roll): `DeepsearchTool.js` (`app.deepsearchTool`) drives the form via `Http.send`, watches `DeepsearchProgressSocket` (cloned from `SeoProgressSocket`, 4013 retry), and wires pause/resume/cancel. `static/css/deepsearch.css` uses the design tokens and is mobile-responsive.
|
||||
- **Progress frame protocol (append-only, the worker<->JS contract):** `phases.py` is the single source of truth for phase identity, shared by `worker.py` (emit) and `DeepsearchTool.js` (render). `PHASE_ORDER = [planning, searching, crawling, indexing, analysis, synthesis]`; `worker._stage(stage, message, phase)` emits BOTH the legacy `stage` frame (byte-identical to before) AND a parallel `phase` frame `{phase, index, total, label}` so the timeline strip advances. The first emitted frame carries `version:1`. Every other frame type and its keys: `substep` (planning angles, `phase`+`message`; also emitted by analysis grounding), `queries`, `candidates`, `rsearch`, `progress` (`done`/`total`/`url`/`depth`), `page_loaded` (now `source`/`render`/`depth`/`elapsed_ms`/`done`/`total`), `page_cached`/`page_skipped`/`page_duplicate` (now `reason`/`elapsed_ms`), `embed_batch` (`batch`/`total_batches`/`backend`/`done`/`total`, emitted before AND after each batch), `embed_done` (`backend`/`chunk_count`), `agent` (`agent` one of summarizer|extractor|linker, `stage`/`status` start|done|failed, with `elapsed_ms`/`tokens_in`/`tokens_out` on done), `report_ready` (now also `synthesis`), `done` (`session_url`), `failed` (`message`). **The contract is append-only: never rename or drop a frame type**; `service._run_worker` pumps every stdout line into the `ProgressHub` untouched, so new frame types reach the WS with no handler change. `tests/api/tools/deepsearch/index.py` is the append-only regression guard.
|
||||
- **RAG-audit hardening (engine correctness, no route/schema change):** (1) **Embedding-dimension consistency** - gateway embeddings carry provider-native dims while `local_embed` is fixed 256-dim; the worker `_index_chunks` now decides the backend ONCE per job (the first gateway failure or non-gateway result forces local for ALL remaining batches), and `VectorStore.add` drops any vector whose length differs from the collection's established dim, so one collection never mixes dims (cosine search across mixed dims is corrupt). `embeddings.EmbedResult.dims` and `VectorStore.dims` (lazily probed from the collection) expose the dimension; `chat.retrieve` re-embeds the query locally and skips retrieval if it still cannot match the stored dim. (2) **Citation grounding** - `orchestrate` drops any finding with no citations, and the summarizer prompt forbids uncited claims and treats the QUESTION as data not an instruction (prompt-injection reduction via `_sanitize_question`); `chat._strip_unmatched_markers` removes any inline `[n]` marker that does not map to an emitted citation. (3) **Confidence calibration** - when only a single domain was crawled, `orchestrate` caps confidence by the source-diversity-derived ceiling (overconfidence guard). (4) `EmbeddingCache` is bounded at `EMBED_CACHE_MAX` to cap in-memory growth.
|
||||
- Devii tools `deepsearch`/`deepsearch_status`/`deepsearch_session` (public); docs `tools-deepsearch`; CLI `devplace deepsearch prune|clear`; audit `deepsearch.run.request|complete|failed` + `deepsearch.chat` (category `tools`). **New dependencies:** `chromadb`, `weasyprint`, `pypdf` (all unpinned). New runtime dirs `config.DEEPSEARCH_DIR`/`DEEPSEARCH_CHROMA_DIR` are registered in `DATA_PATHS`. **Add a dedicated nginx WS `location` for `/tools/deepsearch/{uid}/ws` and `/chat`** above `location /` for production, like the SEO and Devii sockets.
|
||||
|
||||
## AI Usage Analyzer tool - IsslopService (kind `isslop`, `services/jobs/isslop/`, `routers/tools/isslop.py`)
|
||||
|
||||
The public **Tools -> AI Usage Analyzer** classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. It is built on the standard async-job pattern (a `JobService` running a subprocess worker), but its live channel is **pub/sub, not a dedicated WS route**: every worker event is published to `public.isslop.{uid}` AND persisted to `isslop_events`, and the frontend pairs the pub/sub subscription with an incremental `GET /tools/isslop/{uid}/events?after=SEQ` poll, so guests (who cannot subscribe to `public.*` unless `pubsub_allow_guests` is on) and reconnecting tabs replay from the durable trail. **Never rely on pub/sub alone for this tool: the DB event trail is the source of truth, pub/sub is the fast path.**
|
||||
|
||||
- **Engine layout:** `services/jobs/isslop/` holds `acquisition/` (git probe via `git ls-remote`, depth-1 clone with size preflight + live 3 GB kill guard, stealth Playwright website crawler with HTTP fallback, path-traversal-safe workspace helpers), `analysis/` (exclusion rules, stylometric metrics, language detection, per-repo baselines, `signals/` with one detector family per file, two-axis scoring), `agent/` (gateway LLM client, per-file classifier, vision reviewer, report writer with deterministic fallback), plus `pipeline.py` (the event-yielding run), `worker.py` (subprocess entry), `events.py` (frame protocol), `persistence.py` (`EventPersister` writes events/file results/image results/report and stamps the analysis row), `store.py` (all DB access), `badge.py` (SVG), `service.py` (`IsslopService`), `config.py` (all constants + `WorkerSettings`).
|
||||
- **Worker contract:** `IsslopService.process` writes the worker payload (url + admin toggles + gateway endpoint/model/key) to `config.ISSLOP_RUNS_DIR/{uid}/payload.json`, resolves the workspace under `config.ISSLOP_WORKSPACES_DIR` (`workspace_for` rejects any path escaping the root), launches `python -m devplacepy.services.jobs.isslop.worker <payload> <workspace>`, and relays each NDJSON stdout line through `EventPersister.apply` (SQLite) then `pubsub.publish`. The workspace and run dir are removed in a `finally`; the pipeline also deletes the workspace itself as its final act, so **no acquired source survives an analysis** - only the report and its evidence rows.
|
||||
- **AI through the gateway only:** `agent/llm.py` talks solely to `config.INTERNAL_GATEWAY_URL` with model `molodetz` and `database.internal_gateway_key()` (vision uses the same model - the gateway handles image parts). `review_available`/`vision_available` gate the AI and image stages; on any gateway failure the static engine remains authoritative and the report falls back to the deterministic composer. All HTTP (gateway, git size preflight, website fallback crawl) goes through `stealth_async_client`.
|
||||
- **Artifacts are permanent, the job row is not.** Like `ForkService`, `cleanup()` never touches `isslop_analyses`/`isslop_events`/`isslop_file_results`/`isslop_image_results`/`isslop_reports` - the report and badge are public capability URLs meant to outlive the run; the retention sweep removes only the `jobs` tracking row. `devplace isslop clear` is the only bulk hard-delete (plus per-analysis `store.purge_analysis`).
|
||||
- **Ownership and guest history sync:** the owner is `("user", uid)` or `("guest", DEVII_GUEST_COOKIE)` - NOT the tools `_shared.owner_for` IP fallback, because history must survive IP changes and be claimable. The page/list/run handlers mint the guest cookie when absent (same cookie as Devii/customization, one guest identity platform-wide). `_sync_guest_history` runs on page and list requests: when a signed-in user still carries a guest cookie, `store.claim_guest_analyses` re-owns those rows via UPDATE (a move, never a copy - no duplicate data). One active analysis per owner (`429` otherwise, audited `denied`).
|
||||
- **Tables:** `isslop_analyses` is soft-deletable (in `SOFT_DELETE_TABLES`, born-live inserts, reads filter `deleted_at IS NULL`, indexed on `(owner_kind, owner_id, created_at)`/`status`/`content_hash`); the evidence tables (`isslop_events` keyed `(analysis_uid, seq)`, `isslop_file_results`, `isslop_image_results`, `isslop_reports` UNIQUE on `analysis_uid`) are GC-only evidence purged with their analysis. All ensured in `init_db()`.
|
||||
- **Routes** (all under `/tools/isslop`, capability URLs): `GET ""` page, `POST /run` (`IsslopRunForm`, http/git/ssh URL pattern), `GET /list` (owner history), `GET /{uid}` (`IsslopAnalysisOut`), `GET /{uid}/events` (ordered replay), `GET /{uid}/report` (`respond(..., IsslopReportOut)` - HTML shows the live `<dp-isslop-run>` while running and the server-rendered report when completed; the markdown body goes through `render_content`), `GET /{uid}/report.md`, `GET /{uid}/badge.svg` (self-contained SVG, hardcoded colors by design - it must render on external sites). Badge/report URLs are absolute via `seo.site_url`.
|
||||
- **Frontend:** two site-wide web components (`static/js/components/AppIsslop.js` `<dp-isslop>` = submit form + history list; `AppIsslopRun.js` `<dp-isslop-run>` = live progress feed), registered in `components/index.js`, light DOM, reusing `Http`/`Poller` and `app.pubsub`. Page CSS `static/css/isslop.css` (design tokens). On `done` the run component reloads the page so the report is the server-rendered (SEO/`render_content`) version, never a client re-render.
|
||||
- **Verdict blending is per-file, never a global mean (load-bearing).** The AI review pass runs its per-file gateway calls concurrently (`pipeline.AI_REVIEW_CONCURRENCY`, semaphore-bounded like the image pass) and adjusts ONLY the files it actually reviewed: `pipeline.apply_ai_verdicts` blends each sampled file's static origin/quality with its own verdict (0.6/0.4), then the WHOLE repo is re-aggregated with the normal SLOC/criticality weights, and `scoring.ai_fraction` maps per-file origin scores through a smooth 35-65 ramp (never the old hard 45/55 buckets). **Never reintroduce a repo-level mean of the 12 sampled verdicts** - it hands a tiny sample a fixed 40% of the verdict, so the LLM's clustered hedging values (30/40/50) drown thousands of files of static evidence and unrelated projects converge on identical percentages (the real-world twin-84%-human defect). **Single source of truth for the final verdict:** the SCORE event, the DONE payload and `generate_report` all consume the SAME final `RepoScores` (image influence applied via `scoring.adjust_for_images`, which recomputes slop/grade/human together) - the summary grade and the report body grade can therefore never disagree; `tests/unit/services/jobs/isslop/pipeline.py` guards both invariants.
|
||||
- **Image evidence thumbnails + retry-safe evidence.** Workspaces die with the run, so the vision stage persists an aspect-preserving WebP thumbnail per reviewed image (`vision.make_thumbnail`, sha1-of-relative-path name) into `config.ISSLOP_MEDIA_DIR/{uid}` (the dir comes to the worker via the payload `media_dir`); the `thumb` name rides the `image` event, is stored on `isslop_image_results`, and is served by `GET /tools/isslop/{uid}/media/{name}` (strict `^[a-f0-9]{16}\.webp$` name pattern + `is_relative_to` root check - never loosen either). The report page renders the thumbnails with `data-lightbox` (the shared `app.lightbox` opens them full-size) and the live feed shows a tiny inline preview per image event. **`store.reset_evidence(uid)` runs at the top of every `IsslopService.process`** - a retried job (orphan recovery) previously re-inserted its events/file/image rows, duplicating every image and file in the report; any new evidence table MUST be added to `reset_evidence` AND `purge_analysis`.
|
||||
- **Template provenance (the "ships defaults" detector).** `analysis/templates.py` `detect_template(workspace)` scores starter-template evidence repo-wide (it reads files the inventory excludes, like `package.json`): known template slugs/authors in the manifest (+ `ct3aMetadata`), README template marketing (weights capped so a wordy README cannot alone confirm), and the kitchen-sink scaffold constellation (count of standard scaffold artifacts past 3 freebies). The saturating score feeds `scoring.adjust_for_template` - a no-op below 35, a 0.7x floor on ai_percent/origin when confident, 0.85x when >= 70 - applied LAST in the pipeline scoring stage, after the AI and image blends. Near-certain evidence (>= 70) also FORCES category `ai-slop` (defaults shipped as-is are slop by the canonical definition - clean scaffold code never earns an untouched template `sophisticated-ai`, whose meaning is 'the presenter decided'); the confident band caps a `human-*` category at `uncertain`. Calibration truth set (guarded by tests): the six stock boilerplates (ixartz x2, create-t3-app, vercel ai-chatbot x2, fullstack-nextjs-app-template) grade C-D with markers listed, while devplacepy itself scores 0.0 with zero markers - tune weights against BOTH sides, never only the slop set. The evidence rides the `signal` inventory event, the SCORE payload (`template_score`/`template_markers`) and the report's Template Provenance section; admin toggle `isslop_template_detection`.
|
||||
- **Rendered-DOM signal family (`analysis/domsignals/`).** A homepage-only, live-browser companion to the text-based `signals/webtells.py` checks: `acquisition/browser.py`'s `StealthBrowser.capture()` loads the page and returns the actual rendered DOM (computed styles, a class-name census, meta tags, headings/landmarks, console warnings, network response hosts/headers, a screenshot) via `DOM_EXTRACT_SCRIPT` in `acquisition/domcapture.py`. `domsignals/base.py` defines its own `@dom_check`/`@dom_site_check` decorator registry, mirroring the SEO job's `checks/` `@page_check`/`@site_check` mechanics, but emitting isslop's own `Signal` type (not a new one). Eight category modules (`builders`, `color`, `typography`, `layout`, `copy`, `metaseo`, `accessibility`, `buildsignals`) contribute 49 distinct signal codes; `domsignals/aggregate.py` `aggregate_dom_evidence` runs every registered check over the captured page(s) and saturates the weighted total into a `DomEvidence` (score/bucket/signals/detected_builder/builder_confidence). This brings the engine's total to twenty-one detector families (13 text-based in `signals/`, 8 rendered-DOM in `domsignals/`) and 126 signal codes (77 text-based, 49 rendered-DOM) - update these counts again the next time a family is added or removed, never leave them stale.
|
||||
- **Homepage-only by design (`config.DOM_ANALYSIS_MAX_PAGES = 1`).** Capturing a full DOM, console, network trail and screenshot on every crawled page would multiply the browser cost of a run, so the pass runs ONLY against the first page at crawl depth 0 (`acquisition/website.py` gates `depth == 0 and index < DOM_ANALYSIS_MAX_PAGES`). Bump the constant later if the tool needs multi-page DOM coverage - `DomSiteContext`/`dom_site_check` (e.g. `detect_duplicate_meta_description`) already support more than one page. Git-repository sources never populate `dom_snapshots` (`dom_sink` is threaded only through `crawl_website`, never `clone_repository`), and a run where the browser could not be launched simply yields an empty page list, so `aggregate_dom_evidence([])` is a clean no-op (`DomEvidence(score=0.0, bucket="none")`).
|
||||
- **Scoring order is load-bearing: images -> DOM -> template, never reorder.** `pipeline.py` applies `scoring.adjust_for_images`, then `adjust_for_dom_signals`, then `adjust_for_template`, in that exact sequence, and `adjust_for_template` MUST stay last. `adjust_for_dom_signals` blends `DomEvidence.score` into `ai_percent` at a small, deliberately cautious weight (`config.DOM_AI_WEIGHT = 0.12`, since this whole signal family is new and uncalibrated) UNLESS a confident builder match (`builder_confidence >= DOM_BUILDER_CONFIDENT_THRESHOLD`) forces the category to `ai-slop` via the same `_force_slop_category` helper the template detector uses. Because `adjust_for_template` runs after it, a template match can still floor/force the category further; nothing may run after `adjust_for_template`, since a later step would silently undo a forced `ai-slop` category.
|
||||
- **DOM evidence never attaches to a per-file `FileScore`/`FileContext` (do not bolt it on).** `DomEvidence` is repo/page-level evidence blended once into the final `RepoScores`, exactly like template provenance and the image mean - never distributed across individual files. A rendered page has no SLOC and no line numbers to weight against, and its evidence (computed styles, a screenshot, console/network output) is not textual, so it cannot be scored, sampled or displayed through the SLOC-weighted per-file model the rest of the engine uses. Any new DOM check emits page-level `Signal`s into `DomEvidence.signals`, never into a `FileScore.signals` list.
|
||||
- **`isslop_dom_results` follows the same evidence-table obligation as every other isslop table.** It mirrors `isslop_image_results` (`store.py` `TABLE_DOM_RESULTS`) and is already wired into both `store.reset_evidence(uid)` and `store.purge_analysis(uid)`; any future evidence table added under `domsignals/` must be added to both the same way.
|
||||
- **DEP_UNRESOLVED is alias-aware (do not regress).** The JS/TS unresolved-import detector (`signals/hallucination.py`) flags imports that match no package.json dependency (all four sections), Node builtin or local path - i.e. phantom/hallucinated dependencies. It MUST skip everything that is not an npm specifier: relative/absolute/URL imports, `node:` and any `scheme:` specifier, the non-package prefixes `@/`, `~`, `#`, `$` (tsconfig/subpath/Svelte aliases - none are valid npm names), and every prefix parsed from `tsconfig.json`/`jsconfig.json` `compilerOptions.paths` (`engine._javascript_alias_prefixes`, carried on `RepoContext.javascript_alias_prefixes`). tsconfig is JSONC and alias keys contain `/*`, so comments are stripped with the string-aware scanner `_strip_jsonc_comments` - NEVER a comment regex (a regex eats the `"@/*"` alias strings themselves; this was a real defect). Before the alias awareness the detector flagged nearly every file of a standard Next.js app; after, only genuine phantom deps remain. `tests/unit/services/jobs/isslop/hallucination.py` guards it.
|
||||
- **Clickable source references (annotated source viewer).** The static stage persists the FULL source of every signal-bearing file (cap `SOURCE_CAP_FILES`=60 files / 200KB each) into the same per-analysis media dir (`_persist_source`, `s<sha1>.txt`); the name rides the `file` event and the `isslop_file_results.source` column. `GET /tools/isslop/{uid}/source?path=...&line=N` renders `isslop_source.html`: server-rendered line table (Jinja autoescape covers XSS) with line-number anchors `#LN`, signal lines highlighted with inline annotation chips, a focused line, and a findings nav in the sidebar; the strict `^s[a-f0-9]{16}\.txt$` + `is_relative_to` checks mirror the media route. Everything referencing a file links there: the file-results table path, each signal chip (`&line=N#LN`), and the report prose - `_linkify_sources` rewrites backticked paths in the report markdown into links BEFORE `render_content`, and the reporter system prompt requires the model to backtick every path it mentions. `reset_evidence`/`purge_analysis` already sweep the media dir, so sources share the thumbnail lifecycle.
|
||||
- **No absolute paths ever leave the engine:** every user-facing path is workspace-relative (`relative_to(workspace)`); keep it that way in new detectors/events.
|
||||
- **Docs heading anchors + `.docs-toc` (platform-wide):** `docs_prose._render_markdown` post-processes every prose page, stamping a slugified `id` on each `h2`/`h3` (`heading_slug`, GFM-style, deduplicated with `-N` suffixes) and appending a hover-visible `.docs-heading-anchor` permalink; `scroll-margin-top` keeps targets below the topnav. The `isslop-checks` page uses this for its clickable Contents grid: a `.docs-toc` nav placed OUTSIDE the `data-render` block (raw HTML passes through untouched) whose `href="#slug"` values are computed with the SAME `heading_slug` function at generation time - reuse `.docs-toc`/`.docs-toc-item`/`.docs-toc-count` (styled in `docs.css`) for any other long docs page, and never hand-write a slug that `heading_slug` would not produce. `tests/unit/docs_prose.py` guards the slugging and injection.
|
||||
- Devii tools `isslop`/`isslop_status`/`isslop_report`/`isslop_list` (member-only, `requires_auth=True` per policy - the HTTP surface stays public); docs `tools-isslop` + `isslop-checks` (the full plain-language check catalog); CLI `devplace isslop analyze|prune|clear`; audit `isslop.run.request|complete|failed` (category `tools`); achievement key `isslop` ("Slop Hunter"). New unpinned dep `playwright-stealth`; runtime dirs `config.ISSLOP_DIR`/`ISSLOP_WORKSPACES_DIR`/`ISSLOP_RUNS_DIR` in `DATA_PATHS`.
|
||||
@@ -8,16 +8,13 @@ import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import zip_longest
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client
|
||||
|
||||
from .extract import extract_html, relevant_links
|
||||
from .pdf import MAX_PDF_BYTES, extract_pdf_text, is_pdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,47 +25,15 @@ FETCH_TIMEOUT_SECONDS = 20.0
|
||||
MAX_FETCH_BYTES = 2_500_000
|
||||
RESULTS_PER_QUERY = 8
|
||||
CRAWL_CONCURRENCY = 4
|
||||
LINKS_PER_PAGE = 3
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36 DevPlaceDeepSearchBot/1.0"
|
||||
)
|
||||
SCRIPT_STYLE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.DOTALL | re.IGNORECASE)
|
||||
TAG = re.compile(r"<[^>]+>")
|
||||
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.DOTALL | re.IGNORECASE)
|
||||
SPACE = re.compile(r"\s+")
|
||||
MIN_PAGE_CHARS = 200
|
||||
SNIPPET_MIN_CHARS = 120
|
||||
HOSTILE_DOMAINS = (
|
||||
"x.com",
|
||||
"twitter.com",
|
||||
"mobile.twitter.com",
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"m.youtube.com",
|
||||
"reddit.com",
|
||||
"www.reddit.com",
|
||||
"old.reddit.com",
|
||||
"facebook.com",
|
||||
"www.facebook.com",
|
||||
"instagram.com",
|
||||
"www.instagram.com",
|
||||
"linkedin.com",
|
||||
"www.linkedin.com",
|
||||
"tiktok.com",
|
||||
"www.tiktok.com",
|
||||
"threads.net",
|
||||
)
|
||||
WS = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _clean_snippet(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
stripped = TAG.sub(" ", text) if "<" in text and ">" in text else text
|
||||
return WS.sub(" ", stripped).strip()
|
||||
|
||||
|
||||
def _is_hostile(url: str) -> bool:
|
||||
host = urlparse(url).netloc.lower()
|
||||
return any(host == domain or host.endswith("." + domain) for domain in HOSTILE_DOMAINS)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -80,7 +45,6 @@ class CrawledPage:
|
||||
status: int
|
||||
depth: int = 0
|
||||
from_cache: bool = False
|
||||
links: list[tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -97,25 +61,20 @@ def content_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _interleave(buckets: list[list[dict]]) -> list[dict]:
|
||||
merged: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for tier in zip_longest(*buckets):
|
||||
for item in tier:
|
||||
if not item:
|
||||
continue
|
||||
url = item["url"]
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
merged.append(item)
|
||||
return merged
|
||||
def _strip_html(raw: str) -> tuple[str, str]:
|
||||
title_match = TITLE.search(raw)
|
||||
title = SPACE.sub(" ", TAG.sub("", title_match.group(1))).strip() if title_match else ""
|
||||
body = SCRIPT_STYLE.sub(" ", raw)
|
||||
body = TAG.sub(" ", body)
|
||||
body = SPACE.sub(" ", body).strip()
|
||||
return title, body
|
||||
|
||||
|
||||
async def search_queries(
|
||||
queries: list[str], emit: Callable[[dict], None] = lambda frame: None
|
||||
) -> list[dict]:
|
||||
buckets: list[list[dict]] = []
|
||||
results: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
timeout = httpx.Timeout(RSEARCH_TIMEOUT_SECONDS, connect=30.0)
|
||||
async with stealth.stealth_async_client(
|
||||
@@ -125,7 +84,7 @@ async def search_queries(
|
||||
try:
|
||||
response = await client.get(
|
||||
"/search",
|
||||
params={"query": query, "count": RESULTS_PER_QUERY, "content": "true"},
|
||||
params={"query": query, "count": RESULTS_PER_QUERY, "content": "false"},
|
||||
)
|
||||
emit({"type": "rsearch", "endpoint": "/search", "success": response.status_code < 400})
|
||||
if response.status_code >= 400:
|
||||
@@ -135,39 +94,22 @@ async def search_queries(
|
||||
emit({"type": "rsearch", "endpoint": "/search", "success": False})
|
||||
logger.warning("deepsearch rsearch failed for %r: %s", query, exc)
|
||||
continue
|
||||
bucket: list[dict] = []
|
||||
for item in data.get("results") or []:
|
||||
url = (item.get("url") or "").strip()
|
||||
if not url:
|
||||
if not url or url in seen:
|
||||
continue
|
||||
bucket.append(
|
||||
seen.add(url)
|
||||
results.append(
|
||||
{
|
||||
"url": url,
|
||||
"title": item.get("title") or "",
|
||||
"description": item.get("description") or "",
|
||||
"content": item.get("content") or "",
|
||||
"query": query,
|
||||
}
|
||||
)
|
||||
buckets.append(bucket)
|
||||
return _interleave(buckets)
|
||||
return results
|
||||
|
||||
|
||||
def _snippet_page(candidate: dict, depth: int) -> CrawledPage | None:
|
||||
snippet = _clean_snippet(candidate.get("content") or candidate.get("description") or "")
|
||||
if len(snippet) < SNIPPET_MIN_CHARS:
|
||||
return None
|
||||
return CrawledPage(
|
||||
url=candidate["url"],
|
||||
title=_clean_snippet(candidate.get("title") or "") or candidate["url"],
|
||||
text=snippet,
|
||||
source="search",
|
||||
status=200,
|
||||
depth=depth,
|
||||
)
|
||||
|
||||
|
||||
async def _render_with_playwright(url: str) -> tuple[str, str, int, list[tuple[str, str]]]:
|
||||
async def _render_with_playwright(url: str) -> tuple[str, str, int]:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async with async_playwright() as pw:
|
||||
@@ -183,8 +125,8 @@ async def _render_with_playwright(url: str) -> tuple[str, str, int, list[tuple[s
|
||||
await guard_public_url(hop)
|
||||
content = (await page.content())[:MAX_FETCH_BYTES]
|
||||
await context.close()
|
||||
extracted = extract_html(content, base_url=url)
|
||||
return extracted.title, extracted.text, status, extracted.links
|
||||
title, text = _strip_html(content)
|
||||
return title, text, status
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
@@ -198,7 +140,6 @@ async def fetch_page(url: str, depth: int) -> CrawledPage | None:
|
||||
text = ""
|
||||
status = 0
|
||||
source = "httpx"
|
||||
links: list[tuple[str, str]] = []
|
||||
content_type = ""
|
||||
encoding = "utf-8"
|
||||
raw_bytes = b""
|
||||
@@ -231,142 +172,93 @@ async def fetch_page(url: str, depth: int) -> CrawledPage | None:
|
||||
if raw_bytes:
|
||||
try:
|
||||
raw = raw_bytes[:MAX_FETCH_BYTES].decode(encoding, errors="replace")
|
||||
extracted = extract_html(raw, base_url=url)
|
||||
title, text, links = extracted.title, extracted.text, extracted.links
|
||||
title, text = _strip_html(raw)
|
||||
except (LookupError, ValueError) as exc:
|
||||
logger.info("deepsearch decode failed for %s: %s", url, exc)
|
||||
if len(text) < MIN_PAGE_CHARS:
|
||||
try:
|
||||
r_title, r_text, r_status, r_links = await _render_with_playwright(url)
|
||||
r_title, r_text, r_status = await _render_with_playwright(url)
|
||||
if len(r_text) > len(text):
|
||||
title, text, status, source, links = (
|
||||
title, text, status, source = (
|
||||
r_title or title,
|
||||
r_text,
|
||||
r_status or status,
|
||||
"playwright",
|
||||
r_links,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info("deepsearch render failed for %s: %s", url, exc)
|
||||
if len(text) < MIN_PAGE_CHARS:
|
||||
return None
|
||||
return CrawledPage(
|
||||
url=url,
|
||||
title=title or url,
|
||||
text=text,
|
||||
source=source,
|
||||
status=status,
|
||||
depth=depth,
|
||||
links=links,
|
||||
url=url, title=title or url, text=text, source=source, status=status, depth=depth
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_candidate(candidate: dict, depth: int) -> CrawledPage | None:
|
||||
url = candidate["url"]
|
||||
snippet_page = _snippet_page(candidate, depth)
|
||||
if _is_hostile(url):
|
||||
return snippet_page
|
||||
page = await fetch_page(url, depth)
|
||||
if page and snippet_page:
|
||||
return page if len(page.text) >= len(snippet_page.text) else snippet_page
|
||||
return page or snippet_page
|
||||
|
||||
|
||||
async def crawl(
|
||||
candidates: list[dict],
|
||||
max_pages: int,
|
||||
emit: Callable[[dict], None],
|
||||
is_cached: Callable[[str], bool],
|
||||
should_stop: Callable[[], Awaitable[bool]],
|
||||
query: str = "",
|
||||
depth: int = 1,
|
||||
) -> CrawlOutcome:
|
||||
outcome = CrawlOutcome()
|
||||
fetched = 0
|
||||
seen_urls = {candidate["url"] for candidate in candidates}
|
||||
level_candidates = list(candidates)
|
||||
total = min(len(level_candidates), max_pages)
|
||||
cancelled = False
|
||||
for level in range(max(1, depth)):
|
||||
if cancelled or fetched >= max_pages or not level_candidates:
|
||||
total = min(len(candidates), max_pages)
|
||||
for index, candidate in enumerate(candidates):
|
||||
if fetched >= max_pages:
|
||||
break
|
||||
next_candidates: list[dict] = []
|
||||
for start in range(0, len(level_candidates), CRAWL_CONCURRENCY):
|
||||
if fetched >= max_pages:
|
||||
break
|
||||
if await should_stop():
|
||||
emit({"type": "stage", "stage": "cancelled", "message": "Crawl cancelled"})
|
||||
cancelled = True
|
||||
break
|
||||
batch = level_candidates[start : start + CRAWL_CONCURRENCY][: max_pages - fetched]
|
||||
for candidate in batch:
|
||||
emit(
|
||||
{
|
||||
"type": "progress",
|
||||
"done": fetched,
|
||||
"total": total,
|
||||
"url": candidate["url"],
|
||||
"depth": level,
|
||||
"message": f"Reading {candidate['url']}",
|
||||
}
|
||||
)
|
||||
if is_cached(candidate["url"]):
|
||||
emit({"type": "page_cached", "url": candidate["url"], "reason": "seen in a prior run"})
|
||||
fetch_start = time.perf_counter()
|
||||
results = await asyncio.gather(
|
||||
*(_resolve_candidate(candidate, level) for candidate in batch),
|
||||
return_exceptions=True,
|
||||
if await should_stop():
|
||||
emit({"type": "stage", "stage": "cancelled", "message": "Crawl cancelled"})
|
||||
break
|
||||
url = candidate["url"]
|
||||
emit(
|
||||
{
|
||||
"type": "progress",
|
||||
"done": fetched,
|
||||
"total": total,
|
||||
"url": url,
|
||||
"message": f"Fetching {url}",
|
||||
}
|
||||
)
|
||||
if is_cached(url):
|
||||
emit({"type": "page_cached", "url": url, "reason": "seen in a prior run"})
|
||||
fetch_start = time.perf_counter()
|
||||
page = await fetch_page(url, depth=0)
|
||||
elapsed_ms = int((time.perf_counter() - fetch_start) * 1000)
|
||||
if page is None:
|
||||
emit(
|
||||
{
|
||||
"type": "page_skipped",
|
||||
"url": url,
|
||||
"reason": "no readable content",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
)
|
||||
elapsed_ms = int((time.perf_counter() - fetch_start) * 1000)
|
||||
for candidate, page in zip(batch, results):
|
||||
url = candidate["url"]
|
||||
if isinstance(page, BaseException):
|
||||
logger.info("deepsearch fetch crashed for %s: %s", url, page)
|
||||
page = None
|
||||
if page is None:
|
||||
emit(
|
||||
{
|
||||
"type": "page_skipped",
|
||||
"url": url,
|
||||
"reason": "no readable content",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if fetched >= max_pages:
|
||||
break
|
||||
digest = content_hash(page.text)
|
||||
if digest in outcome.seen_hashes:
|
||||
emit(
|
||||
{
|
||||
"type": "page_duplicate",
|
||||
"url": url,
|
||||
"reason": "duplicate content",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
)
|
||||
continue
|
||||
outcome.seen_hashes.add(digest)
|
||||
outcome.pages.append(page)
|
||||
fetched += 1
|
||||
emit(
|
||||
{
|
||||
"type": "page_loaded",
|
||||
"url": page.url,
|
||||
"title": page.title,
|
||||
"source": page.source,
|
||||
"depth": level,
|
||||
"render": page.source == "playwright",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"done": fetched,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
if level + 1 < depth:
|
||||
for link in relevant_links(page.links, query, LINKS_PER_PAGE):
|
||||
if link not in seen_urls:
|
||||
seen_urls.add(link)
|
||||
next_candidates.append({"url": link})
|
||||
level_candidates = next_candidates
|
||||
total = min(total + len(next_candidates), max_pages)
|
||||
continue
|
||||
digest = content_hash(page.text)
|
||||
if digest in outcome.seen_hashes:
|
||||
emit(
|
||||
{
|
||||
"type": "page_duplicate",
|
||||
"url": url,
|
||||
"reason": "duplicate content",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
)
|
||||
continue
|
||||
outcome.seen_hashes.add(digest)
|
||||
outcome.pages.append(page)
|
||||
fetched += 1
|
||||
emit(
|
||||
{
|
||||
"type": "page_loaded",
|
||||
"url": page.url,
|
||||
"title": page.title,
|
||||
"source": page.source,
|
||||
"render": page.source == "playwright",
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"done": fetched,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
return outcome
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user