Compare commits
4 Commits
e331e15be9
...
d7ec58ac46
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ec58ac46 | |||
| 3e4ef738b8 | |||
| d0fd3b4539 | |||
| 5f4f324721 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,6 +5,7 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.env
|
||||
agents/reports/
|
||||
devplace.db*
|
||||
devplace-services.lock
|
||||
devplace-init.lock
|
||||
|
||||
91
AGENTS.md
91
AGENTS.md
@ -415,6 +415,18 @@ if "comments" not in db.tables:
|
||||
|
||||
**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:
|
||||
|
||||
```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. 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. 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).
|
||||
|
||||
## HTML/JSON content negotiation
|
||||
|
||||
Every page/redirect endpoint also returns JSON when the client asks. Core in
|
||||
@ -604,6 +616,8 @@ Opening a URL with a `#comment-<uid>` fragment scrolls the comment into view and
|
||||
- **Saving through the admin form is the reliable mutation path**, not a direct DB write: the form handler runs in the server process and calls `clear_settings_cache()`, so the change takes effect immediately (a direct DB write waits out the 60s server-side cache).
|
||||
- **`conftest.py`'s `app_server` seeds `rate_limit_per_minute="1000000"`** so the suite is never throttled and the settings form round-trips a safe value instead of the template's `"60"` default.
|
||||
- **Rate-limit unit tests patch `m.get_int_setting`**, not the `RATE_LIMIT` constant - the constant is now only the fallback default passed to `get_int_setting`.
|
||||
- **Service enabled/disabled state is global state too - restore it.** Stopping a service through `POST /admin/services/{name}/stop` sets its `service_{name}_enabled` setting to `"0"` for the rest of the session. A test that stops a service (e.g. `test_gateway_disabled_returns_503` stops `openai` to assert the gateway returns 503) MUST restart it in a `try/finally` (`POST /admin/services/{name}/start`), and any `finally` that ends in `stop` should end in `start` instead - the default, expected state is enabled. Leaving the gateway stopped makes a later file's request hit `is_enabled()` first and get a `503` where it expected a `401`/`200`. The `openai` gateway's `handle()` checks `is_enabled()` (503) BEFORE `authorize()` (401), so a stopped gateway masks the auth result entirely.
|
||||
- **The 1,000,000/min rate-limit seed only applies after the server's 60s settings cache expires.** `conftest.py` seeds `rate_limit_per_minute` with a raw DB insert (no `clear_settings_cache()`), so for the first ~60s of server uptime the server still uses the `60`/min default. A high-volume request loop (e.g. `test_auth_matrix`) run in the FIRST minute can therefore trip a spurious `429`; in the full suite that test runs well past the window so it never does. If you reproduce a `429` only in a tiny isolated run, it is this warm-up artifact, not a real auth/rate-limit bug.
|
||||
|
||||
### Failure handling
|
||||
|
||||
@ -626,6 +640,14 @@ Opening a URL with a `#comment-<uid>` fragment scrolls the comment into view and
|
||||
| Tests fail in sequence | Session-scoped context + `clear_cookies()` per test |
|
||||
| Double messages in chat | Deduplicate by message UID with `seen` set |
|
||||
| Avatar generation fails | Falls back to initial-based SVG - check multiavatar import |
|
||||
| `Http.getJson` returns HTML / `JSON.parse` throws | The helper sends `Accept: application/json`; a content-negotiated route (`respond(..., model=...)`) needs it. Don't hand-roll `fetch()` without that header against a negotiated route |
|
||||
| Page renders but data empty / 500 only after a CLI test ran earlier | A partial insert created the table with a reduced schema. Ensure the full column set in `init_db()` (see "Dataset rules -> `init_db()` MUST create every queried column") |
|
||||
| `no such column: X` 500 in the server log | Same root cause: a queried/indexed column is missing because the table was created lazily by a partial insert. Add the column to the `init_db()` ensure-block |
|
||||
| Action button click times out ("element is not visible") | The button moved into the `.project-actions-overflow` menu. Open it first: click `.project-actions-more`, then `.context-menu-item:has-text('<Label>')` (see `test_projects.py`) |
|
||||
| `ImportError: cannot import name X from devplacepy.utils` in a test | The util was renamed (e.g. `badge_info` -> `get_badge`). Update the test import to the current name; grep `templating.py` globals for the new name |
|
||||
| Admin page heading assertion fails | Admin pages use `<h2>` inside `.admin-toolbar`, not `<h1>` - assert `text=` or `.admin-toolbar h2:has-text(...)` |
|
||||
| `test_auth_matrix` reports `422` instead of `401`/`403` | A POST route's form params are undocumented in `docs_api.py`, so the matrix sends an empty body and FastAPI validation (422) fires before the auth guard. Document every required form param so auth is reached and tested |
|
||||
| `test_auth_matrix` reports `503` on a gateway endpoint | A prior test left the `openai` service stopped. Restore it in `finally` (see "Global `site_settings` tests -> Service enabled/disabled state") |
|
||||
|
||||
## Anatomy of a feature (the relationship map)
|
||||
|
||||
@ -1558,3 +1580,72 @@ Devii can partially configure its OWN system message: every system prompt ends w
|
||||
- **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 + 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.
|
||||
|
||||
## Maintenance agents (`agents/`)
|
||||
|
||||
A fleet of autonomous AI maintenance agents lives in `agents/`. They are NOT part of the running web app; they are developer tooling that keeps the codebase consistent. The complete design document is `agents.md` at the repo root; the public, newbie-facing docs are the `/docs/maintenance-agents.html` (overview) and `/docs/maintenance-usage.html` (commands) prose pages, registered in `routers/docs.py` under `SECTION_MAINTENANCE`, plus the "Self-Maintaining by Design" section on the landing page (`templates/landing.html` + `.landing-bots-*` in `static/css/landing.css`).
|
||||
|
||||
- **Shared engine.** Every agent is a thin specialization of the proven `agents/agent.py` (async ReAct loop: plan -> investigate -> reflect -> verify gate; `@tool` registry; `delegate`/`spawn`/`swarm` fan-out; BM25 `retrieve`; molodetz gateway primary, deepseek fallback). `agents/core/` re-exports that engine as the documented module surface; `agents/base.py` defines the `MaintenanceAgent` contract (system prompt + `scope_units()` + check/fix run + report writing). Each specialist (`security.py`, `audit.py`, `devii.py`, `docs.py`, `fanout.py`, `dry.py`, `style.py`, `frontend.py`, `seo.py`, `test_coverage.py`) is a small subclass supplying a domain mandate and scope.
|
||||
- **Registry and runners.** `agents/fleet.py` holds `REGISTRY` and the dependency `ORDER`. `agents/orchestrator.py` runs the whole fleet and writes `agents/reports/fleet-<date>.json`. `agents/maestro.py` is the conversational conductor: it exposes each specialist as a `run_*` tool plus `run_fleet_tool`/`read_report`, defaults questions to check mode, and confirms before any fix run. `agents/__main__.py` dispatches `python -m agents <name|all|maestro>`.
|
||||
- **Modes and reports.** `--fix` (default) edits and verifies; `--check` reports only and exits non-zero on findings. Every run writes `agents/reports/<agent>-<date>.{json,md}`. The finding schema is `{severity, dimension, file, line, rule, message, fixed, fix_summary}`.
|
||||
- **Verification (no external tools).** `agents/validator.py` is a dependency-free multi-language validator and is the agents' `verify()` default (`python -m agents.validator .`): Python via `ast.parse` + `py_compile`, JavaScript via `node --check`, CSS via brace balance, HTML and Jinja templates via Jinja2 `parse`. It is also wired as `make validate` and needs no external validator.
|
||||
- **Make targets.** `make <name>-agent` (with `CHECK=1` for report-only), `make agents-all` (+ `CHECK=1`), `make maestro`, `make validate`.
|
||||
- **Guardrails.** No git writes; destructive edits confirmation-gated; the test agent writes tests but never runs the suite; agents obey the same rules they enforce (headers, no em-dash, no stray comments).
|
||||
- **Self-exclusion (hard rule).** The `agents/` directory is OFF-LIMITS to the fleet and to every other checker, fixer, or cleanup (Claude included). It holds the detection patterns themselves (em-dash characters, forbidden-name examples, `rm -rf` strings, HTML entities) as DATA, not violations, so scanning it yields false positives and fixing it corrupts the detectors. Every agent run calls `agent.protect_agents()`, which makes the engine's write tools hard-reject any path under `agents/` (`_protected_guard` in `agents/agent.py`), and the base system prompt forbids reading or scanning it. This is why a stray em-dash fixer once edited the engine's own prose; it can no longer happen.
|
||||
- **Report names carry a codename.** Reports are `agents/reports/<agent>-<codename>-<date>.{json,md}` where `<codename>` is a random memorable adjective-animal label (`zesty-ferret`); the orchestrator writes `fleet-<codename>-<date>.json`. The codename is generated once at run start (`core.report_codename`) so the start banner and the filename match. The JSON also carries `incomplete` (true if the run hit its iteration limit) and `cost` (`run_usd`/`session_usd`, fixed-point strings). Reports are pruned to the newest `core.REPORTS_KEEP` (25) per agent type via `core.prune_reports`, so `agents/reports/` stays bounded.
|
||||
- **Start banner.** `base.run` prints, at the moment each agent starts, its icon + name + codename + dimension, exactly what it will do (scan-only, scan-and-fix with the file budget, or fix N seeded findings), and the report path. So the operator always knows which agent is running. `AGENT_ICONS` maps each agent name to an emoji.
|
||||
- **Live output.** Every output line is prefixed with `HH:MM:SS +HH:MM:SS │` (wall clock + elapsed, fixed width, via `install_timestamps` wrapping stdout/stderr at the entrypoints). After each AI call a `💸/💰/📊` cost line streams (`_cost_emit`, pricing mirrors the gateway). Every file edit streams a colored unified diff of the changed hunks (`_emit_diff` in the write tools). `run_command`/`verify` stream stdout/stderr live under a PTY (`_stream_pty`), so even block-buffered children (Python) appear line by line.
|
||||
- **Code-level safety rails (not prompts).** `run_command` is removed from the maintenance toolset AND hard-refused by the engine during a run (`set_shell_restricted`), so an agent cannot shell out to scan or mass-edit. A per-run write budget (`set_write_budget`, default 20 distinct files; raised to fit a seeded fix) blocks runaway sweeps. The `agents/` write-guard (`protect_agents`/`_protected_guard`) blocks any write under `agents/`. All three reset in `base.run`'s `finally`. **A run is atomic:** `base.run` holds an `asyncio.Lock` around the whole reset-record-read-clear cycle, so the shared run-state globals (findings, budget, protection, cost delta) can never be corrupted by an overlapping run (e.g. Maestro firing two `run_*` tools in one turn); concurrent runs serialize.
|
||||
- **Fix is seeded from the check.** In Maestro, asking to fix after a check passes the confirmed findings into the fix run (`_LAST_CHECK` -> `run(seed_findings=...)`), so the fix goes straight to the files instead of re-scanning; cheap and deterministic. Standalone fix runs scan-and-fix in one pass.
|
||||
- **Big-file editing.** `edit_file` falls back to whitespace-tolerant line matching when an exact match fails; `replace_lines`/`insert_lines`/`delete_lines` edit by line number (read with `read_lines` first) for reliable edits of large generated files like `docs_api.py`. Context compaction (`find_compaction_split`) keeps the recent tail and summarizes the bulk, so large files in context do not balloon cost or thrash.
|
||||
|
||||
|
||||
### `security` agent
|
||||
|
||||
**Dimension:** Route authorization, private-data and admin guards, input validation.
|
||||
Sweeps every route declaration (`routers/*.py`) and model (`models.py`) for correct `require_user`/`require_admin` decorators, checks that private fields (email, API keys) are excluded from serialisation for non-owners, validates that user-supplied identifiers are scoped (a user cannot read or mutate another user's data through an ID parameter), and confirms `Form`/`Query` models have adequate type/range validation.
|
||||
|
||||
### `audit` agent
|
||||
|
||||
**Dimension:** Every state change leaves an audit-log entry.
|
||||
Sweeps every mutation route (`POST`/`PUT`/`DELETE`/`PATCH` in `routers/*.py`) for a call to `services.audit.record()`. Flags any mutation that lacks an audit call. Knows the common false-positive patterns (avatar proxy, static-file redirects, gateway passthrough) and ignores them.
|
||||
|
||||
### `devii` agent
|
||||
|
||||
**Dimension:** Devii capability parity and role-gated tool visibility.
|
||||
Sweeps `services/devii/` to verify that every documented Devii tool (`http` catalog actions + instance tools) is wired and that admin-only tools are correctly gated behind `requires_auth=True` + a role check. Checks that the Devii tool list served at runtime matches the feature catalogue in `AGENTS.md`.
|
||||
|
||||
### `docs` agent
|
||||
|
||||
**Dimension:** CLAUDE.md / AGENTS.md / README / `/docs` accuracy and role-aware visibility.
|
||||
Cross-references `docs_api.py` endpoint entries against real routes, checks `DOCS_PAGES` admin flags, verifies `visible_pages` filtering, validates Jinja section gating on prose templates, and confirms README.md / AGENTS.md / CLAUDE.md factual claims match the source code.
|
||||
|
||||
### `fanout` agent
|
||||
|
||||
**Dimension:** A feature is wired across every layer (form, schema, Devii tool, docs, SEO).
|
||||
For every new or changed feature, confirms it appears in every layer: the HTML form/template, the Pydantic model, the Devii tool catalogue, the docs reference page, the sitemap (if public), and the relevant test file. Flags missing layers.
|
||||
|
||||
### `dry` agent
|
||||
|
||||
**Dimension:** Duplication removed; shared helpers reused.
|
||||
Scans for repeated code patterns (same SQL query in multiple routers, same form validation in multiple endpoints, same template block in multiple pages) and proposes extraction into shared utilities, partial templates, or base classes.
|
||||
|
||||
### `style` agent
|
||||
|
||||
**Dimension:** Naming, headers, typing, formatting rules.
|
||||
Enforces the project's coding conventions: file header line (`# retoor <retoor@molodetz.nl>`), no stray comments in source, no em-dash characters, `snake_case` for Python, `kebab-case` for HTML/CSS, `camelCase` for JS, `async def` for all route handlers, typed function signatures, and consistent import ordering.
|
||||
|
||||
### `frontend` agent
|
||||
|
||||
**Dimension:** ES6 modules, web components, CSS structure.
|
||||
Validates that every JavaScript file is an ES6 module (`export`/`import`, no IIFE globals), every custom element extends `HTMLElement` and registers with `customElements.define`, no inline scripts in templates (use `<script type="module" src=...>`), and CSS follows the established BEM-like naming with component-scoped files in `static/css/`.
|
||||
|
||||
### `seo` agent
|
||||
|
||||
**Dimension:** Search metadata and sitemap coverage.
|
||||
Confirms every public page has a `<title>`, `<meta name="description">`, Open Graph tags (`og:title`, `og:description`, `og:image`, `og:url`), and a `link rel="canonical"`. Verifies the sitemap (`/sitemap.xml`) includes every public paginated route and that `robots.txt` allows/disallows correctly.
|
||||
|
||||
### `test` agent
|
||||
|
||||
**Dimension:** Integration-test coverage (writes tests, never runs the suite).
|
||||
For every route or feature change, writes Playwright and/or `TestClient` tests covering the happy path, the auth-matrix (guest/member/admin), error/edge cases, and any multi-user interaction. Follows the established test patterns in `tests/`. Does not run the test suite; the developer runs `make test` separately.
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@ -119,7 +119,7 @@ Docs (`routers/docs.py` `DOCS_PAGES`) entries take optional `admin: True` (hidde
|
||||
Session cookies named `session`, value is a 64-char hex token from `secrets.token_hex(32)`. Passwords hashed with `pbkdf2_sha256` via passlib. `get_current_user(request)` returns user dict or `None`, with a per-process TTL cache (`_user_cache`, 300s) keyed by token. `require_user(request)` raises a 303 redirect to `/` for guests; `require_admin(request)` redirects to `/feed` for non-admins. Public pages (feed, news detail, project detail, gists) use `get_current_user` so guests can browse - POSTs are all guarded by `require_user`.
|
||||
|
||||
### Database (`devplacepy/database.py`)
|
||||
SQLite via `dataset.connect` with WAL + 30s busy timeout + 256MB mmap. `init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except, and seed defaults for `site_settings` only insert when the key doesn't already exist.
|
||||
SQLite via `dataset.connect` with WAL + 30s busy timeout + 256MB mmap. `init_db()` is idempotent - every index is created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except, 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 (see `AGENTS.md` -> "Dataset rules -> `init_db()` MUST create every queried column").
|
||||
|
||||
**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.
|
||||
|
||||
@ -191,7 +191,7 @@ The profile **Media** tab lists every attachment a user uploaded (`get_user_medi
|
||||
|
||||
## Testing
|
||||
|
||||
Playwright (NOT pytest-playwright - uninstall if present, it conflicts). 419 tests across 34 files in `tests/` (mixed Playwright UI tests and in-process `TestClient`/asyncio unit tests). The fixture stack:
|
||||
Playwright (NOT pytest-playwright - uninstall if present, it conflicts). 849+ tests across 63 files in `tests/` (mixed Playwright UI tests and in-process `TestClient`/asyncio unit tests). The fixture stack:
|
||||
|
||||
- `app_server` (session-scoped): spawns uvicorn as a subprocess on `_xdist_port()` (10501 + worker index) with a tempfile DB.
|
||||
- `browser_context` (session-scoped): one Playwright context shared by all tests.
|
||||
@ -247,3 +247,11 @@ Read the router (`routers/{area}.py`), template (`templates/{area}.html`), test
|
||||
- **Never run tests unless the user explicitly asks.**
|
||||
|
||||
`AGENTS.md` is the long-form companion to this file with every domain-specific pitfall (notifications, gists CodeMirror setup, public feed rules, post editing, news detail comments, etc.) and the full feature relationship map. Read it when working on an unfamiliar area.
|
||||
|
||||
## Maintenance agents and validation
|
||||
|
||||
The `agents/` directory is a fleet of autonomous AI maintenance agents (developer tooling, not part of the running app). Each agent enforces one quality dimension across the repo and runs in `--fix` (default) or `--check` mode. The internals are documented in `AGENTS.md` (the "Maintenance agents" section); the public docs are `/docs/maintenance-agents.html` and `/docs/maintenance-usage.html`. Targets: `make <name>-agent` (`CHECK=1` for report-only), `make agents-all`, `make maestro`.
|
||||
|
||||
**HARD RULE - the `agents/` directory is OFF-LIMITS to every code checker, fixer, linter, style pass, or refactor, including the maintenance agents themselves, Claude, and any other tool or human cleanup.** That directory deliberately contains the exact patterns the agents hunt for - em-dash characters, forbidden-name examples (`_temp`, `_v2`, `my_`), destructive-command strings (`rm -rf`), and HTML entities - as DETECTION DATA, not as violations. "Fixing" them corrupts the detectors and breaks the fleet. The maintenance agents enforce this themselves: every run calls `agent.protect_agents()`, which makes the engine's write tools (`write_file`/`edit_file`/`patch_file`/`create_file`) hard-reject any path under `agents/`, and their system prompt forbids reading or scanning it. Do NOT em-dash-strip, rename, reformat, or otherwise "tidy" anything in `agents/`. Leave it exactly as authored.
|
||||
|
||||
Code validation uses `agents/validator.py` (run via `make validate` or `python -m agents.validator .`): a dependency-free validator covering Python (`ast` + `py_compile`), JavaScript (`node --check`), CSS (brace balance), and HTML/Jinja templates (Jinja2 parse). Use it in place of any external validator when checking touched files.
|
||||
|
||||
60
Makefile
60
Makefile
@ -128,3 +128,63 @@ deploy:
|
||||
git checkout production
|
||||
git merge master
|
||||
git push origin production
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code validation. Dependency-free, no external tools: Python ast+py_compile,
|
||||
# JavaScript node --check, CSS brace balance, HTML/Jinja template parse.
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: validate
|
||||
|
||||
validate:
|
||||
python -m agents.validator .
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maintenance agent fleet (see agents.md). Default mode is fix; pass CHECK=1
|
||||
# for read-only reporting with a non-zero exit on findings.
|
||||
# make audit-agent # autonomous fix
|
||||
# make audit-agent CHECK=1 # report only
|
||||
# make agents-all # run the whole fleet
|
||||
# make maestro # talk to the conductor; it runs the rest
|
||||
# ---------------------------------------------------------------------------
|
||||
CHECK ?=
|
||||
AGENT_MODE := $(if $(CHECK),--check,--fix)
|
||||
|
||||
.PHONY: security-agent audit-agent devii-agent docs-agent fanout-agent \
|
||||
dry-agent style-agent frontend-agent seo-agent test-agent \
|
||||
agents-all maestro
|
||||
|
||||
security-agent:
|
||||
python -m agents.security $(AGENT_MODE)
|
||||
|
||||
audit-agent:
|
||||
python -m agents.audit $(AGENT_MODE)
|
||||
|
||||
devii-agent:
|
||||
python -m agents.devii $(AGENT_MODE)
|
||||
|
||||
docs-agent:
|
||||
python -m agents.docs $(AGENT_MODE)
|
||||
|
||||
fanout-agent:
|
||||
python -m agents.fanout $(AGENT_MODE)
|
||||
|
||||
dry-agent:
|
||||
python -m agents.dry $(AGENT_MODE)
|
||||
|
||||
style-agent:
|
||||
python -m agents.style $(AGENT_MODE)
|
||||
|
||||
frontend-agent:
|
||||
python -m agents.frontend $(AGENT_MODE)
|
||||
|
||||
seo-agent:
|
||||
python -m agents.seo $(AGENT_MODE)
|
||||
|
||||
test-agent:
|
||||
python -m agents.test_coverage $(AGENT_MODE)
|
||||
|
||||
agents-all:
|
||||
python -m agents.orchestrator $(AGENT_MODE)
|
||||
|
||||
maestro:
|
||||
python -m agents.maestro
|
||||
|
||||
55
README.md
55
README.md
@ -65,8 +65,9 @@ devplacepy/
|
||||
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
|
||||
| `/profile` | Profile view, editing, and a public **Media** tab (`?tab=media`) showing every attachment a user uploaded, newest first |
|
||||
| `/media` | Per-attachment soft delete and restore: `POST /media/{uid}/delete` (owner or admin), `POST /media/{uid}/restore` (admin) |
|
||||
| `/messages` | Direct messaging |
|
||||
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
|
||||
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
|
||||
| `/messages` | Direct messaging |
|
||||
| `/votes` | Upvote/downvote on posts, comments, projects |
|
||||
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
@ -203,7 +204,7 @@ pre-filled. Each panel has a response-format selector (defaulting to JSON) that
|
||||
**Expected** tab showing the modeled response, and a **Live response** tab for the real
|
||||
result. To document a new endpoint, add an entry to `docs_api.py`; the page, sidebar link,
|
||||
examples, runner, and expected-response sample are generated automatically. FastAPI's
|
||||
built-in Swagger is disabled so `/docs` belongs to this site.
|
||||
built-in Swagger is moved to `/swagger` so `/docs` belongs to this site. ReDoc is at `/redoc` and the raw schema at `/openapi.json`.
|
||||
|
||||
Operator pages are admin-only: `/docs/admin.html` (user/news/settings administration) and
|
||||
`/docs/services.html` (Background Services) are hidden from the sidebar and return 404 for
|
||||
@ -496,6 +497,54 @@ tools: `tool_create`, `tool_list`, `tool_get`, `tool_update` (including enable/d
|
||||
the same engine that powers virtual tools. Self-evaluation depth is bounded so a tool cannot loop by
|
||||
calling itself.
|
||||
|
||||
## Maintenance agents
|
||||
|
||||
DevPlace ships a fleet of autonomous AI maintenance agents under `agents/` that
|
||||
keep the codebase consistent. Each agent owns one quality dimension, sweeps the
|
||||
whole repository for problems in that dimension, and either reports them or fixes
|
||||
them and verifies the build. The internals are documented in `AGENTS.md`; the
|
||||
public, newbie-friendly guide is at `/docs/maintenance-agents.html` and
|
||||
`/docs/maintenance-usage.html`.
|
||||
|
||||
Every agent runs in two modes: `--fix` (default, autonomous) or `--check`
|
||||
(report only, non-zero exit on findings). The agents share one proven engine
|
||||
(`agents/agent.py`) and verify their own work with `agents/validator.py`, a
|
||||
dependency-free validator (Python `ast`/`py_compile`, JavaScript `node --check`,
|
||||
CSS brace balance, HTML and Jinja templates parsed with Jinja2).
|
||||
|
||||
| Agent | Dimension |
|
||||
|-------|-----------|
|
||||
| `maestro` | Conversational conductor; runs the right agent or the whole fleet for you |
|
||||
| `security` | Route authorization, private-data and admin guards, input validation |
|
||||
| `audit` | Every state change leaves an audit-log entry |
|
||||
| `devii` | Devii capability parity and role-gated tool visibility |
|
||||
| `docs` | CLAUDE.md / AGENTS.md / README / `/docs` accuracy and role-aware visibility |
|
||||
| `fanout` | A feature is wired across every layer (form, schema, Devii tool, docs, SEO) |
|
||||
| `dry` | Duplication removed; shared helpers reused |
|
||||
| `style` | Naming, headers, typing, formatting rules |
|
||||
| `frontend` | ES6 modules, web components, CSS structure |
|
||||
| `seo` | Search metadata and sitemap coverage |
|
||||
| `test` | Integration-test coverage (writes tests, never runs the suite) |
|
||||
|
||||
```bash
|
||||
make maestro # talk to the conductor; it runs the rest
|
||||
make validate # validate the whole codebase (no external tools)
|
||||
make audit-agent # fix one dimension autonomously
|
||||
make audit-agent CHECK=1 # report only
|
||||
make agents-all # run the whole fleet
|
||||
make agents-all CHECK=1 # CI gate across the fleet
|
||||
```
|
||||
|
||||
Every run is legible live: each agent prints a start banner (icon, name, a
|
||||
memorable codename, what it will do, and its report path), every line is prefixed
|
||||
with the wall-clock time and elapsed duration, each AI call streams its running
|
||||
dollar cost, and every file edit streams a unified diff. Reports land in
|
||||
`agents/reports/` as `<agent>-<codename>-<date>.{json,md}` (the orchestrator writes
|
||||
`fleet-<codename>-<date>.json`). Safety is enforced in code, not prompts: agents
|
||||
cannot edit anything under `agents/`, cannot shell out, and cannot modify more than
|
||||
a fixed number of files per run, and a run that exhausts its step budget is marked
|
||||
`incomplete` and exits non-zero rather than looking clean.
|
||||
|
||||
## Push notifications & PWA
|
||||
|
||||
Authenticated users can receive native web push notifications, and the site is an
|
||||
@ -575,7 +624,7 @@ SQLite is synchronous by design and will never be made async. It is more than fa
|
||||
|
||||
## Testing
|
||||
|
||||
- **419 tests** across 34 files: Playwright integration + in-process unit tests
|
||||
- **849+ tests** across 63 files: Playwright integration + in-process unit tests
|
||||
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
|
||||
- Runs in parallel via pytest-xdist (`make test` = `-n auto --dist loadfile`); each worker is an isolated uvicorn subprocess on port 10501+ with its own temp database and `DEVPLACE_DATA_DIR`
|
||||
- `loadfile` keeps every test in a file on one worker so session fixtures and ordering hold; cap workers with `make test TEST_WORKERS=4`
|
||||
|
||||
1
agents/__init__.py
Normal file
1
agents/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
54
agents/__main__.py
Normal file
54
agents/__main__.py
Normal file
@ -0,0 +1,54 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .fleet import ordered_agents
|
||||
|
||||
|
||||
def _usage() -> str:
|
||||
names = ", ".join(ordered_agents())
|
||||
return (
|
||||
"usage: python -m agents <command> [options]\n\n"
|
||||
"commands:\n"
|
||||
f" <agent> run one agent: {names}\n"
|
||||
" all run the whole fleet (orchestrator)\n"
|
||||
" maestro open the conversational conductor\n\n"
|
||||
"examples:\n"
|
||||
" python -m agents security --check\n"
|
||||
" python -m agents all --check\n"
|
||||
" python -m agents maestro \"is my audit coverage complete?\"\n"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
||||
sys.stdout.write(_usage())
|
||||
sys.exit(0)
|
||||
command = sys.argv[1]
|
||||
rest = sys.argv[2:]
|
||||
sys.argv = [f"agents.{command}", *rest]
|
||||
if command == "all":
|
||||
from .orchestrator import main as run
|
||||
|
||||
run()
|
||||
return
|
||||
if command == "maestro":
|
||||
from .maestro import main as run
|
||||
|
||||
run()
|
||||
return
|
||||
if command in ordered_agents():
|
||||
from .fleet import REGISTRY
|
||||
from .base import cli_main
|
||||
|
||||
sys.argv[0] = f"agents.{command}"
|
||||
cli_main(REGISTRY[command]())
|
||||
return
|
||||
sys.stderr.write(f"unknown command '{command}'\n\n{_usage()}")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2848
agents/agent.py
Executable file
2848
agents/agent.py
Executable file
File diff suppressed because it is too large
Load Diff
49
agents/audit.py
Normal file
49
agents/audit.py
Normal file
@ -0,0 +1,49 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class AuditAgent(MaintenanceAgent):
|
||||
name = "audit"
|
||||
description = "audit log coverage maintainer"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Guarantee that every state-changing action emits a correct audit record, that the event catalogue is "
|
||||
"complete, and that denials and failures are logged with the right result.\n\n"
|
||||
"DETECT:\n"
|
||||
"- Any mutation lacking an audit record on its success path is an error. A mutation is a @router.post / "
|
||||
"@router.put / @router.delete, a .insert / .update / .delete DB write, or a background-service, scheduler, or "
|
||||
"CLI state change. The record is audit.record(request, ...) in request contexts or audit.record_system(...) "
|
||||
"in request-less contexts.\n"
|
||||
"- Guard and denial branches missing result=\"denied\", and failure branches missing result=\"failure\", are errors.\n"
|
||||
"- Event keys used in code but absent from events.md are errors; a new domain not mapped in "
|
||||
"services/audit/categories.py category_for is an error.\n"
|
||||
"- Double-counting is an error: the HTTP path and the Devii agent path for the same mutation must be disjoint "
|
||||
"(dispatcher _audit_mechanic covers the agent path; the route covers the HTTP path). A record gated on the "
|
||||
"action (so a logging failure would block it) is an error; recording is best-effort and never raises.\n\n"
|
||||
"FIX: add the recorder call at the mutation point with the correct event key, origin, via_agent, and result, "
|
||||
"never gating the action on it; extend events.md with the new key in the right domain; extend category_for for "
|
||||
"a new domain; route the call through the existing DRY choke point (content.py, the project_files.py helpers, "
|
||||
"routers/containers.py _audit_instance, the Devii dispatcher _audit_mechanic) rather than scattering call sites."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("routers", "devplacepy/routers/*.py - every mutating route has audit.record on success and result on denial"),
|
||||
("content-choke", "devplacepy/content.py create/edit/delete record at the choke point"),
|
||||
("project-files", "devplacepy/project_files.py file/dir mutations recorded; read-only guard records denied"),
|
||||
("containers", "devplacepy/routers/containers.py _audit_instance covers lifecycle/exec/schedule"),
|
||||
("services", "devplacepy/services/* (news, jobs, containers, devii) use record_system with origin"),
|
||||
("catalogue", "events.md keys vs code keys; services/audit/categories.py category_for domain coverage"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(AuditAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
244
agents/base.py
Normal file
244
agents/base.py
Normal file
@ -0,0 +1,244 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from . import core
|
||||
from .agent import (
|
||||
AgentState,
|
||||
MarkdownRenderer,
|
||||
clear_protected_trees,
|
||||
clear_write_budget,
|
||||
close_http_client,
|
||||
cost_session_total,
|
||||
format_usd,
|
||||
install_timestamps,
|
||||
protect_agents,
|
||||
react_loop,
|
||||
set_shell_restricted,
|
||||
set_write_budget,
|
||||
usd_str,
|
||||
_with_datetime,
|
||||
)
|
||||
|
||||
DEFAULT_MAX_ITER = 60
|
||||
WRITE_BUDGET = 20
|
||||
|
||||
_RUN_LOCK = asyncio.Lock()
|
||||
|
||||
AGENT_ICONS = {
|
||||
"security": "\U0001F6E1",
|
||||
"audit": "\U0001F4CB",
|
||||
"devii": "\U0001F6E0",
|
||||
"docs": "\U0001F4DA",
|
||||
"fanout": "\U0001F310",
|
||||
"dry": "â™»",
|
||||
"style": "\U0001F3A8",
|
||||
"frontend": "\U0001F5A5",
|
||||
"seo": "\U0001F50D",
|
||||
"test": "\U0001F9EA",
|
||||
}
|
||||
|
||||
MAINT_HEADER = """You are {name}, an autonomous maintenance agent for the DevPlace codebase, a FastAPI plus Jinja2 platform using the dataset library over SQLite, with pure ES6 module JavaScript on the frontend. You enforce exactly ONE quality dimension across the repository and nothing else.
|
||||
|
||||
ABSOLUTE EXCLUSION (NON-NEGOTIABLE): The `agents/` directory is the maintenance fleet's OWN source code. You MUST NOT read it for analysis, grep it, scan it, report on it, or modify it under any circumstances. It deliberately contains the very patterns you hunt for (em-dash characters, forbidden-name examples, destructive-command strings, HTML entities) as DETECTION DATA, not as violations. A "violation" found in `agents/` is never real. Always exclude `agents/` from every grep and glob (the search root is the application code, not the tooling). The engine also hard-blocks any write under `agents/`, so an attempt to fix something there will fail by design. Treat `agents/` as if it does not exist.
|
||||
|
||||
OPERATING PROTOCOL
|
||||
1. PLAN FIRST. Your VERY FIRST tool call MUST be plan() with goal, steps, success_criteria, and confidence.
|
||||
2. INVESTIGATE BEFORE CONCLUDING. Use grep, glob_files, list_dir, find_symbol, read_file, read_lines, and retrieve to gather evidence. Never assume a violation; confirm it against the source.
|
||||
3. TOOL DISCIPLINE. Use the structured grep tool for pattern detection; do NOT shell out with run_command to scan, lint, or loop over files (run_command is for verify only). For a grep-able check (a character, a name, a header line) grep; do not read a whole file, and never read a large file (over ~400 lines) just to look for a pattern - grep it or read_lines the relevant range. Never repeat a grep or re-read a file you already read this run.
|
||||
4. ONE report_finding PER ISSUE. Every confirmed issue MUST be recorded with report_finding(dimension, severity, message, file, line, rule). Do not describe issues only in prose.
|
||||
5. SHARD WIDE. The codebase is large. Work through the scope units below one at a time. You MAY call delegate(task) to investigate an independent unit in a fresh context and fold its result back in.
|
||||
6. STAY IN YOUR LANE. Only this dimension. Record an unrelated problem at most as a single info finding. Respect "refactor only what you touch": do NOT mass-rewrite pre-existing files for a cosmetic rule they never followed; that is noise, not maintenance.
|
||||
|
||||
{mode_rules}
|
||||
|
||||
OBEY THE RULES YOU ENFORCE: never write comments or docstrings in source you author, never use an em-dash (use a hyphen), keep the `retoor <retoor@molodetz.nl>` header as the first line of any file you create, never run the test suite, never perform any git write operation.
|
||||
|
||||
DIMENSION MANDATE
|
||||
{mandate}
|
||||
"""
|
||||
|
||||
CHECK_RULES = "MODE: CHECK (read-only). You MUST NOT modify any file; the writing tools are not available to you. Investigate and record every issue with report_finding (fixed=false). End with a one-line summary."
|
||||
|
||||
FIX_RULES = "MODE: FIX (autonomous). For every issue, record it with report_finding (set fixed=true once corrected) AND apply a minimal, idiomatic fix. You MUST read an existing file (read_file, or read_lines for a range) before editing it. Prefer edit_file for surgical changes; for LARGE files, read_lines the target range and edit by line number with replace_lines/insert_lines/delete_lines instead of pasting big strings - it is far more reliable. After all edits, call verify('python -m agents.validator .') and ensure it passes. End with a one-line summary."
|
||||
|
||||
|
||||
class MaintenanceAgent:
|
||||
name: str = "maintenance"
|
||||
description: str = ""
|
||||
|
||||
def mandate(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return []
|
||||
|
||||
def system_prompt(self, mode: str) -> str:
|
||||
rules = CHECK_RULES if mode == "check" else FIX_RULES
|
||||
return MAINT_HEADER.format(name=self.name, mode_rules=rules, mandate=self.mandate())
|
||||
|
||||
def task_prompt(self, mode: str, scope: Optional[str], seed_findings: Optional[list[dict]] = None) -> str:
|
||||
if mode == "fix" and seed_findings:
|
||||
listed = "\n".join(
|
||||
f"- {f.get('file','')}:{f.get('line') or '?'} [{f.get('rule') or f.get('dimension','')}] {f.get('message','')}"
|
||||
for f in seed_findings[:60]
|
||||
)
|
||||
return (
|
||||
f"Dimension: {self.description}\n\n"
|
||||
f"FIX THESE {len(seed_findings)} CONFIRMED FINDINGS from a prior check. Do NOT re-scan the codebase; "
|
||||
f"go straight to fixing. For EACH finding: read_file the target file, apply a minimal idiomatic fix, "
|
||||
f"then record it with report_finding(..., fixed=true) (or fixed=false with a reason if it genuinely "
|
||||
f"cannot be fixed). When all edits are done call verify('python -m agents.validator .') once and ensure "
|
||||
f"it passes. Exclude the `agents/` directory.\n\n{listed}\n\n"
|
||||
f"Finish with a single line: 'N findings (E errors, W warnings), M fixed.'"
|
||||
)
|
||||
units = self.scope_units()
|
||||
if scope:
|
||||
units = [unit for unit in units if unit[0] == scope] or units
|
||||
listed = "\n".join(f"- {label}: {focus}" for label, focus in units)
|
||||
action = "record each issue" if mode == "check" else "record AND immediately fix each issue as you find it"
|
||||
return (
|
||||
f"Dimension: {self.description}\n\n"
|
||||
f"Scope units to sweep ({mode} mode):\n{listed}\n\n"
|
||||
f"Be efficient: investigate, then {action} via report_finding. Do not re-read a file you already read or "
|
||||
f"repeat a grep. Record findings as you go rather than saving them all for the end. "
|
||||
f"Exclude the `agents/` directory from every grep and glob; it is the fleet's own source and is off-limits. "
|
||||
f"Finish with a single line: 'N findings (E errors, W warnings), M fixed.'"
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
mode: str,
|
||||
scope: Optional[str],
|
||||
max_iter: int,
|
||||
renderer: Optional[MarkdownRenderer],
|
||||
seed_findings: Optional[list[dict]] = None,
|
||||
) -> dict:
|
||||
async with _RUN_LOCK:
|
||||
return await self._run_locked(mode, scope, max_iter, renderer, seed_findings)
|
||||
|
||||
async def _run_locked(
|
||||
self,
|
||||
mode: str,
|
||||
scope: Optional[str],
|
||||
max_iter: int,
|
||||
renderer: Optional[MarkdownRenderer],
|
||||
seed_findings: Optional[list[dict]],
|
||||
) -> dict:
|
||||
core.reset_findings()
|
||||
protect_agents()
|
||||
set_shell_restricted(True)
|
||||
budget = WRITE_BUDGET
|
||||
if seed_findings:
|
||||
distinct = {f.get("file") for f in seed_findings if f.get("file")}
|
||||
budget = max(WRITE_BUDGET, len(distinct) + 2)
|
||||
set_write_budget(budget)
|
||||
cost_before = cost_session_total()["cost"]
|
||||
started = datetime.now()
|
||||
codename = core.report_codename()
|
||||
if renderer is not None:
|
||||
icon = AGENT_ICONS.get(self.name, "\U0001F916")
|
||||
scope_note = f" (scope: {scope})" if scope else ""
|
||||
if mode == "fix" and seed_findings:
|
||||
plan_line = f"fix {len(seed_findings)} confirmed finding(s) from the last check, then verify the build"
|
||||
elif mode == "fix":
|
||||
plan_line = f"scan{scope_note}, fix each issue, and verify the build (up to {budget} files this run)"
|
||||
else:
|
||||
plan_line = f"scan{scope_note} read-only and report each issue (no files changed)"
|
||||
report_file = core.report_path(self.name, codename, started)
|
||||
renderer.print(
|
||||
f"\n{icon} **{self.name} agent** `{codename}` starting -- {self.description}.\n"
|
||||
f"I will {plan_line}.\n"
|
||||
f"Report -> `{report_file}.json`"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": _with_datetime(self.system_prompt(mode))},
|
||||
{"role": "user", "content": self.task_prompt(mode, scope, seed_findings)},
|
||||
]
|
||||
state = AgentState()
|
||||
try:
|
||||
final = await react_loop(
|
||||
messages=messages,
|
||||
tools_payload=core.payloads_for(mode),
|
||||
state=state,
|
||||
max_iterations=max_iter,
|
||||
renderer=renderer,
|
||||
)
|
||||
finally:
|
||||
clear_protected_trees()
|
||||
clear_write_budget()
|
||||
set_shell_restricted(False)
|
||||
finished = datetime.now()
|
||||
incomplete = final is None or state.iteration >= max_iter
|
||||
session_cost = cost_session_total()["cost"]
|
||||
run_cost = session_cost - cost_before
|
||||
cost = {"run_usd": usd_str(run_cost), "session_usd": usd_str(session_cost)}
|
||||
report = core.write_reports(
|
||||
self.name, mode, started, finished, cost=cost, incomplete=incomplete, codename=codename
|
||||
)
|
||||
summary = report["summary"]
|
||||
if incomplete:
|
||||
exit_code = 1
|
||||
elif mode == "check":
|
||||
exit_code = 1 if summary["total"] > 0 else 0
|
||||
else:
|
||||
exit_code = 1 if summary["unfixed"] > 0 else 0
|
||||
result = {
|
||||
"name": self.name,
|
||||
"mode": mode,
|
||||
"summary": summary,
|
||||
"cost": cost,
|
||||
"incomplete": incomplete,
|
||||
"json": report["json"],
|
||||
"items": report["items"],
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
if renderer is not None:
|
||||
status = " âš INCOMPLETE (hit the iteration limit; results are partial)" if incomplete else ""
|
||||
renderer.print(
|
||||
f"\n**{self.name}** [{mode}] -> {summary['total']} findings "
|
||||
f"({summary['errors']} errors, {summary['warnings']} warnings), "
|
||||
f"{summary['fixed']} fixed.{status} \U0001F4B0 {format_usd(run_cost)} this run "
|
||||
f"({format_usd(session_cost)} session). Report: `{report['json']}`"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_parser(name: str, description: str) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog=f"agents.{name}", description=description)
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--fix", dest="mode", action="store_const", const="fix", help="Autonomously fix findings (default)")
|
||||
mode.add_argument("--check", dest="mode", action="store_const", const="check", help="Report only; non-zero exit on findings")
|
||||
parser.set_defaults(mode="fix")
|
||||
parser.add_argument("--scope", default=None, help="Restrict to one scope unit label")
|
||||
parser.add_argument("--max-iter", type=int, default=DEFAULT_MAX_ITER, help="Maximum agent iterations")
|
||||
parser.add_argument("--no-color", action="store_true", help="Disable ANSI colour output")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
|
||||
return parser
|
||||
|
||||
|
||||
async def _amain(agent: MaintenanceAgent, argv: Optional[list[str]] = None) -> int:
|
||||
install_timestamps()
|
||||
args = build_parser(agent.name, agent.description).parse_args(argv)
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
renderer = MarkdownRenderer(use_color=not args.no_color)
|
||||
try:
|
||||
result = await agent.run(args.mode, args.scope, args.max_iter, renderer)
|
||||
return result["exit_code"]
|
||||
finally:
|
||||
await close_http_client()
|
||||
|
||||
|
||||
def cli_main(agent: MaintenanceAgent) -> None:
|
||||
try:
|
||||
sys.exit(asyncio.run(_amain(agent)))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
249
agents/core/__init__.py
Normal file
249
agents/core/__init__.py
Normal file
@ -0,0 +1,249 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
CODENAME_ADJECTIVES = (
|
||||
"brave", "calm", "clever", "swift", "gentle", "bright", "bold", "cosmic", "lucky", "mighty",
|
||||
"noble", "quiet", "rapid", "sunny", "witty", "eager", "jolly", "keen", "merry", "proud",
|
||||
"sleek", "spry", "vivid", "zesty", "amber", "azure", "coral", "fuzzy", "golden", "happy",
|
||||
"snappy", "cozy", "breezy", "plucky", "dapper", "nimble", "quirky", "steady", "tidy", "wise",
|
||||
)
|
||||
CODENAME_ANIMALS = (
|
||||
"otter", "badger", "panda", "koala", "heron", "tiger", "gecko", "raven", "moose", "bison",
|
||||
"crane", "dingo", "ferret", "marmot", "walrus", "puffin", "ibis", "lemur", "tapir", "quokka",
|
||||
"narwhal", "ocelot", "wombat", "gibbon", "meerkat", "mongoose", "capybara", "axolotl", "pangolin", "armadillo",
|
||||
)
|
||||
|
||||
|
||||
def report_codename() -> str:
|
||||
return f"{random.choice(CODENAME_ADJECTIVES)}-{random.choice(CODENAME_ANIMALS)}"
|
||||
|
||||
from ..agent import (
|
||||
tool,
|
||||
react_loop,
|
||||
AgentState,
|
||||
get_tool,
|
||||
get_tool_payloads,
|
||||
MarkdownRenderer,
|
||||
close_http_client,
|
||||
_with_datetime,
|
||||
)
|
||||
|
||||
REPORTS_DIR = Path(__file__).resolve().parent.parent / "reports"
|
||||
|
||||
WRITE_TOOLS = (
|
||||
"create_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"patch_file",
|
||||
"replace_lines",
|
||||
"insert_lines",
|
||||
"delete_lines",
|
||||
)
|
||||
WEB_TOOLS = (
|
||||
"web_search",
|
||||
"deep_search",
|
||||
"ai_search",
|
||||
"fetch_url",
|
||||
"download_file",
|
||||
"download_files",
|
||||
"describe_image",
|
||||
)
|
||||
SWARM_TOOLS = (
|
||||
"spawn",
|
||||
"swarm_wait",
|
||||
"swarm_result",
|
||||
"swarm_tail",
|
||||
"swarm_kill",
|
||||
"swarm_cleanup",
|
||||
)
|
||||
SEVERITIES = ("error", "warning", "info")
|
||||
|
||||
_FINDINGS: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def reset_findings() -> None:
|
||||
_FINDINGS.clear()
|
||||
|
||||
|
||||
def findings() -> list[dict[str, Any]]:
|
||||
return list(_FINDINGS)
|
||||
|
||||
|
||||
@tool
|
||||
async def report_finding(
|
||||
dimension: str,
|
||||
severity: str,
|
||||
message: str,
|
||||
file: str = "",
|
||||
line: int = 0,
|
||||
rule: str = "",
|
||||
fixed: bool = False,
|
||||
fix_summary: str = "",
|
||||
):
|
||||
"""Record one maintenance finding for the run report. Call once per confirmed issue.
|
||||
dimension: The check that produced this finding.
|
||||
severity: One of error, warning, info.
|
||||
message: Human-readable description of the issue.
|
||||
file: Repository-relative path of the offending file.
|
||||
line: 1-based line number when known, else 0.
|
||||
rule: Short machine rule id, e.g. mutation-without-record.
|
||||
fixed: True if this run already applied a fix.
|
||||
fix_summary: One line describing the fix when fixed is true.
|
||||
"""
|
||||
severity_value = severity if severity in SEVERITIES else "warning"
|
||||
_FINDINGS.append(
|
||||
{
|
||||
"severity": severity_value,
|
||||
"dimension": dimension,
|
||||
"file": file,
|
||||
"line": int(line) if line else 0,
|
||||
"rule": rule,
|
||||
"message": message,
|
||||
"fixed": bool(fixed),
|
||||
"fix_summary": fix_summary,
|
||||
}
|
||||
)
|
||||
return json.dumps({"status": "success", "recorded": True, "total_findings": len(_FINDINGS)})
|
||||
|
||||
|
||||
def payloads_for(mode: str) -> list[dict[str, Any]]:
|
||||
exclude = WEB_TOOLS + SWARM_TOOLS + ("run_command",)
|
||||
if mode == "check":
|
||||
exclude = exclude + WRITE_TOOLS + ("verify",)
|
||||
return get_tool_payloads(exclude=exclude)
|
||||
|
||||
|
||||
def payloads_named(names: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for name in names:
|
||||
func = get_tool(name)
|
||||
if func is not None:
|
||||
out.append(func._tool_payload)
|
||||
return out
|
||||
|
||||
|
||||
def summarize(items: list[dict[str, Any]]) -> dict[str, int]:
|
||||
errors = sum(1 for f in items if f["severity"] == "error")
|
||||
warnings = sum(1 for f in items if f["severity"] == "warning")
|
||||
fixed = sum(1 for f in items if f["fixed"])
|
||||
return {
|
||||
"total": len(items),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"fixed": fixed,
|
||||
"unfixed": len(items) - fixed,
|
||||
}
|
||||
|
||||
|
||||
def _md_report(name: str, mode: str, summary: dict[str, int], items: list[dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
f"# {name} report ({mode})",
|
||||
"",
|
||||
f"- total: {summary['total']}",
|
||||
f"- errors: {summary['errors']}",
|
||||
f"- warnings: {summary['warnings']}",
|
||||
f"- fixed: {summary['fixed']}",
|
||||
f"- unfixed: {summary['unfixed']}",
|
||||
"",
|
||||
"## Findings",
|
||||
"",
|
||||
]
|
||||
if not items:
|
||||
lines.append("None.")
|
||||
return "\n".join(lines)
|
||||
by_file: dict[str, list[dict[str, Any]]] = {}
|
||||
for finding in items:
|
||||
by_file.setdefault(finding["file"] or "(repository)", []).append(finding)
|
||||
for path, group in sorted(by_file.items()):
|
||||
lines.append(f"### {path}")
|
||||
for finding in group:
|
||||
location = f":{finding['line']}" if finding["line"] else ""
|
||||
mark = "fixed" if finding["fixed"] else finding["severity"]
|
||||
label = finding["rule"] or finding["dimension"]
|
||||
lines.append(f"- [{mark}] {label}{location}: {finding['message']}")
|
||||
if finding["fixed"] and finding["fix_summary"]:
|
||||
lines.append(f" fix: {finding['fix_summary']}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
REPORTS_KEEP = 25
|
||||
|
||||
|
||||
def report_path(name: str, codename: str, started: datetime) -> Path:
|
||||
stamp = started.strftime("%Y%m%d-%H%M%S")
|
||||
return REPORTS_DIR / f"{name}-{codename}-{stamp}"
|
||||
|
||||
|
||||
def prune_reports(prefix: str, keep: int = REPORTS_KEEP) -> None:
|
||||
existing = sorted(
|
||||
REPORTS_DIR.glob(f"{prefix}-*.json"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for stale in existing[keep:]:
|
||||
for companion in (stale, stale.with_suffix(".md")):
|
||||
try:
|
||||
companion.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_reports(
|
||||
name: str,
|
||||
mode: str,
|
||||
started: datetime,
|
||||
finished: datetime,
|
||||
cost: Optional[dict[str, Any]] = None,
|
||||
incomplete: bool = False,
|
||||
codename: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
items = findings()
|
||||
summary = summarize(items)
|
||||
codename = codename or report_codename()
|
||||
stamp = started.strftime("%Y%m%d-%H%M%S")
|
||||
base = REPORTS_DIR / f"{name}-{codename}-{stamp}"
|
||||
payload = {
|
||||
"agent": name,
|
||||
"codename": codename,
|
||||
"mode": mode,
|
||||
"started_at": started.isoformat(),
|
||||
"finished_at": finished.isoformat(),
|
||||
"incomplete": incomplete,
|
||||
"summary": summary,
|
||||
"cost": cost or {},
|
||||
"findings": items,
|
||||
}
|
||||
base.with_suffix(".json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
base.with_suffix(".md").write_text(_md_report(name, mode, summary, items), encoding="utf-8")
|
||||
prune_reports(name)
|
||||
return {"summary": summary, "json": str(base.with_suffix(".json")), "items": items}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"tool",
|
||||
"react_loop",
|
||||
"AgentState",
|
||||
"MarkdownRenderer",
|
||||
"close_http_client",
|
||||
"_with_datetime",
|
||||
"report_finding",
|
||||
"reset_findings",
|
||||
"findings",
|
||||
"payloads_for",
|
||||
"payloads_named",
|
||||
"summarize",
|
||||
"write_reports",
|
||||
"report_codename",
|
||||
"report_path",
|
||||
"prune_reports",
|
||||
"REPORTS_DIR",
|
||||
]
|
||||
13
agents/core/cost.py
Normal file
13
agents/core/cost.py
Normal file
@ -0,0 +1,13 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import ( # noqa: F401
|
||||
cost_session_total,
|
||||
cost_record,
|
||||
set_cost_stream,
|
||||
format_usd,
|
||||
usd_str,
|
||||
PER_MILLION,
|
||||
PRICE_CACHE_HIT_PER_M,
|
||||
PRICE_CACHE_MISS_PER_M,
|
||||
PRICE_OUTPUT_PER_M,
|
||||
)
|
||||
3
agents/core/exec_tools.py
Normal file
3
agents/core/exec_tools.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import run_command, verify, stream_subprocess # noqa: F401
|
||||
3
agents/core/fs_tools.py
Normal file
3
agents/core/fs_tools.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import read_file, read_lines, write_file, create_file, edit_file, patch_file, list_dir, glob_files, grep, find_symbol # noqa: F401
|
||||
3
agents/core/llm.py
Normal file
3
agents/core/llm.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import Backend, get_backends, vision_available, llm_call, _call_backend, MODEL, DEEPSEEK_MODEL # noqa: F401
|
||||
3
agents/core/loop.py
Normal file
3
agents/core/loop.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import AgentState, react_loop, compact_messages, context_size, find_compaction_split # noqa: F401
|
||||
3
agents/core/prompts.py
Normal file
3
agents/core/prompts.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import SYSTEM_PROMPT, SUB_AGENT_SYSTEM_PROMPT, system_message, _with_datetime # noqa: F401
|
||||
3
agents/core/render.py
Normal file
3
agents/core/render.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import MarkdownRenderer # noqa: F401
|
||||
3
agents/core/runner.py
Normal file
3
agents/core/runner.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import run_once, interactive, amain, main # noqa: F401
|
||||
3
agents/core/swarm.py
Normal file
3
agents/core/swarm.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import delegate, spawn, swarm_wait, swarm_result, swarm_tail, swarm_kill, swarm_cleanup, retrieve, CorpusIndex, get_corpus_index # noqa: F401
|
||||
3
agents/core/tools.py
Normal file
3
agents/core/tools.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import tool, get_tool, get_tool_payloads, execute_tool_call, coerce_tool_args, validate_tool_args, _build_function_payload # noqa: F401
|
||||
3
agents/core/web_tools.py
Normal file
3
agents/core/web_tools.py
Normal file
@ -0,0 +1,3 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from ..agent import web_search, deep_search, ai_search, fetch_url, download_file, download_files, describe_image # noqa: F401
|
||||
47
agents/devii.py
Normal file
47
agents/devii.py
Normal file
@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class DeviiAgent(MaintenanceAgent):
|
||||
name = "devii"
|
||||
description = "Devii capability and role-gated tool-list maintainer"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Guarantee that Devii can perform, via REST, everything the site offers to the logged-in user's role, and "
|
||||
"that the tool list presented to a given user exposes only the tools that role may call. A non-admin must not "
|
||||
"even see that admin tools exist.\n\n"
|
||||
"DETECT:\n"
|
||||
"- Enumerate every REST route across devplacepy/routers/*.py and diff against CATALOG.by_name(). Every route a "
|
||||
"user could reasonably ask Devii to perform has a corresponding Action. A user-facing capability with no Devii "
|
||||
"action is a finding.\n"
|
||||
"- Each Action's requires_auth and requires_admin flags exactly match its route's guard. An admin-guarded route "
|
||||
"exposed as a non-admin Devii action is a security-grade error; a public route wrongly marked requires_auth=True "
|
||||
"is a capability gap.\n"
|
||||
"- Catalog.tool_schemas_for(authenticated, is_admin) withholds an admin tool's schema from a non-admin, and the "
|
||||
"dispatcher still raises AuthRequiredError if a non-admin names it. Confirm both halves hold for every action; a "
|
||||
"tool whose schema leaks to the wrong role is an error.\n"
|
||||
"- Irreversible Devii actions are in CONFIRM_REQUIRED.\n\n"
|
||||
"FIX: add the missing Action in the correct handler module with the right method, path, requires_auth, and "
|
||||
"requires_admin; correct a misaligned auth flag. Never grant a member an admin capability to close a parity gap; "
|
||||
"an admin-only capability with no member action is left admin-only. Hand new-tool documentation to the docs agent."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("route-parity", "devplacepy/routers/*.py routes vs services/devii/actions/catalog.py CATALOG.by_name()"),
|
||||
("flag-alignment", "each Action requires_auth/requires_admin matches the route guard"),
|
||||
("role-visibility", "services/devii/actions/spec.py tool_schemas_for: no admin schema reaches a non-admin"),
|
||||
("dispatch-guard", "services/devii/actions/dispatcher.py AuthRequiredError on requires_admin; CONFIRM_REQUIRED"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(DeviiAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
48
agents/docs.py
Normal file
48
agents/docs.py
Normal file
@ -0,0 +1,48 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class DocsAgent(MaintenanceAgent):
|
||||
name = "docs"
|
||||
description = "documentation coverage and role-aware show/hide maintainer"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"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.\n\n"
|
||||
"DETECT:\n"
|
||||
"- 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.\n"
|
||||
"- Every prose page's factual claims match the code (routes, env vars, defaults, behavior). A stale claim is an error.\n"
|
||||
"- 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.\n"
|
||||
"- 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.\n"
|
||||
"- 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.\n\n"
|
||||
"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."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("api-docs", "devplacepy/docs_api.py endpoint() coverage vs routers/*.py routes"),
|
||||
("page-gating", "devplacepy/routers/docs.py DOCS_PAGES admin flag; visible_pages filter; docs_search indexing"),
|
||||
("section-gating", "templates/docs/*.html Jinja {% if user.role == 'admin' %} on admin sections"),
|
||||
("readme", "README.md reflects current routes, env vars, dependencies, features"),
|
||||
("agents-md", "AGENTS.md has a domain section for every mechanic; CLAUDE.md only for new rules"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(DocsAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
43
agents/dry.py
Normal file
43
agents/dry.py
Normal file
@ -0,0 +1,43 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class DryAgent(MaintenanceAgent):
|
||||
name = "dry"
|
||||
description = "duplication and reuse enforcement"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Eliminate duplicated logic and re-implementations of the canonical shared utilities.\n\n"
|
||||
"DETECT:\n"
|
||||
"- Backend: inline N+1 loops where a batch helper exists (get_users_by_uids, get_comment_counts_by_post_uids, "
|
||||
"get_vote_counts, load_comments, build_pagination, _in_clause); per-router Jinja2Templates instead of the shared "
|
||||
"templating.templates; inline avatar or user links instead of the _avatar_link.html / _user_link.html partials.\n"
|
||||
"- Frontend: hand-rolled fetch instead of Http; bespoke polling instead of Poller; bespoke job polling instead of "
|
||||
"JobPoller; click-to-POST controllers not extending OptimisticAction; floating windows not extending FloatingWindow.\n"
|
||||
"- General: blocks of duplicated logic that should be extracted into a shared helper.\n\n"
|
||||
"FIX: replace the call site with the existing utility, or extract a new shared helper and route the duplicate "
|
||||
"call sites through it; extractions follow the project's small-files structure and must not change behavior. When "
|
||||
"similarity is below a confidence threshold, record an info finding for human review rather than auto-extracting."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("batch-helpers", "routers/*.py use database.py batch helpers, not inline N+1 loops"),
|
||||
("templates", "every router imports templating.templates, never its own Jinja2Templates"),
|
||||
("partials", "_avatar_link.html / _user_link.html reused, not inline avatar/user markup"),
|
||||
("frontend-http", "static/js/*.js use Http, not hand-rolled fetch"),
|
||||
("frontend-poll", "static/js/*.js use Poller / JobPoller, not bespoke loops"),
|
||||
("frontend-base", "controllers extend OptimisticAction; windows extend FloatingWindow"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(DryAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
agents/fanout.py
Normal file
46
agents/fanout.py
Normal file
@ -0,0 +1,46 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class FeatureFanoutAgent(MaintenanceAgent):
|
||||
name = "fanout"
|
||||
description = "cross-layer feature completeness checker"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Enforce the 'Anatomy of a feature' checklist: for each route, every layer of the fan-out exists and agrees.\n\n"
|
||||
"DETECT, for each route:\n"
|
||||
"- Input has a models.py Form model declared as data: Annotated[SomeForm, Form()] (or a documented raw-form "
|
||||
"exception for file uploads).\n"
|
||||
"- If the route serves JSON via respond(..., model=XOut), every context key the route returns exists on XOut. "
|
||||
"A key returned but absent from the schema is silently dropped and is an error.\n"
|
||||
"- The route returns HTML and JSON through respond (or pure JSON via JSONResponse) consistently.\n"
|
||||
"- A services/devii/actions/catalog.py Action exists if the route is something a user could ask Devii to do.\n"
|
||||
"- A docs_api.py entry exists for every public or authenticated endpoint.\n"
|
||||
"- Public pages build base_seo_context.\n"
|
||||
"- README.md and AGENTS.md mention the feature.\n\n"
|
||||
"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."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("forms", "devplacepy/models.py Form model exists for each mutating route input"),
|
||||
("schemas", "devplacepy/schemas.py *Out has every key returned by respond(model=XOut)"),
|
||||
("respond", "routers/*.py serve HTML+JSON via respond consistently"),
|
||||
("devii-action", "services/devii/actions/catalog.py Action exists for user-facing routes"),
|
||||
("api-docs", "devplacepy/docs_api.py entry for each public/auth endpoint"),
|
||||
("seo-readme", "seo.py base_seo_context for public pages; README/AGENTS mention the feature"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(FeatureFanoutAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
47
agents/fleet.py
Normal file
47
agents/fleet.py
Normal file
@ -0,0 +1,47 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .audit import AuditAgent
|
||||
from .devii import DeviiAgent
|
||||
from .docs import DocsAgent
|
||||
from .dry import DryAgent
|
||||
from .fanout import FeatureFanoutAgent
|
||||
from .frontend import FrontendAgent
|
||||
from .security import SecurityAgent
|
||||
from .seo import SeoAgent
|
||||
from .style import StyleAgent
|
||||
from .test_coverage import TestAgent
|
||||
|
||||
ORDER = [
|
||||
"style",
|
||||
"dry",
|
||||
"security",
|
||||
"audit",
|
||||
"devii",
|
||||
"seo",
|
||||
"frontend",
|
||||
"fanout",
|
||||
"docs",
|
||||
"test",
|
||||
]
|
||||
|
||||
REGISTRY = {
|
||||
"style": StyleAgent,
|
||||
"dry": DryAgent,
|
||||
"security": SecurityAgent,
|
||||
"audit": AuditAgent,
|
||||
"devii": DeviiAgent,
|
||||
"seo": SeoAgent,
|
||||
"frontend": FrontendAgent,
|
||||
"fanout": FeatureFanoutAgent,
|
||||
"docs": DocsAgent,
|
||||
"test": TestAgent,
|
||||
}
|
||||
|
||||
|
||||
def ordered_agents(only: list[str] | None = None) -> list[str]:
|
||||
if not only:
|
||||
return list(ORDER)
|
||||
wanted = {name.strip() for name in only if name.strip()}
|
||||
return [name for name in ORDER if name in wanted]
|
||||
42
agents/frontend.py
Normal file
42
agents/frontend.py
Normal file
@ -0,0 +1,42 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class FrontendAgent(MaintenanceAgent):
|
||||
name = "frontend"
|
||||
description = "ES6, component, and CSS consistency"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Keep the frontend conformant to the project's strict ES6 and component rules.\n\n"
|
||||
"DETECT:\n"
|
||||
"- One class per ES6 module, instantiated and reachable via the global app, with Application.js as the root.\n"
|
||||
"- Custom dp- components extend Component, self-register via customElements.define at the bottom of their file, "
|
||||
"render into the light DOM (no shadow root so global CSS applies), and inject their own CSS <link> on "
|
||||
"instantiation if absent.\n"
|
||||
"- CSS uses variables (the design tokens), and pages are responsive down to very small phones.\n"
|
||||
"- CDN scripts in templates/base.html use defer or type=\"module\" so the Playwright domcontentloaded wait does "
|
||||
"not time out.\n\n"
|
||||
"FIX: split a multi-class module, add the missing customElements.define, remove a shadow root, add the dynamic CSS "
|
||||
"link injection, replace a hard-coded color with a token, or add defer to a CDN script. Never introduce a JS "
|
||||
"framework, NPM, or a build step. Visual judgement is out of scope for auto-fix and is recorded as a finding."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("one-class", "static/js/*.js one class per module, instantiated on app"),
|
||||
("components", "static/js/components/*.js extend Component, define, light DOM, CSS link injection"),
|
||||
("css-tokens", "static/css/*.css use design-token variables; responsive to small phones"),
|
||||
("cdn-scripts", "templates/base.html CDN scripts use defer or type=module"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(FrontendAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
274
agents/maestro.py
Normal file
274
agents/maestro.py
Normal file
@ -0,0 +1,274 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import core
|
||||
from .agent import (
|
||||
AgentState,
|
||||
MarkdownRenderer,
|
||||
close_http_client,
|
||||
get_tool,
|
||||
install_timestamps,
|
||||
react_loop,
|
||||
tool,
|
||||
_with_datetime,
|
||||
)
|
||||
from .fleet import REGISTRY, ordered_agents
|
||||
from .orchestrator import run_fleet
|
||||
|
||||
MAESTRO_AGENT_MAX_ITER = 60
|
||||
MAX_RETURNED_FINDINGS = 12
|
||||
|
||||
_LAST_RESULTS: dict[str, dict[str, Any]] = {}
|
||||
_LAST_CHECK: dict[str, list[dict[str, Any]]] = {}
|
||||
_RENDERER: Optional[MarkdownRenderer] = None
|
||||
|
||||
|
||||
def _set_renderer(renderer: MarkdownRenderer) -> None:
|
||||
global _RENDERER
|
||||
_RENDERER = renderer
|
||||
|
||||
|
||||
async def _dispatch(name: str, mode: str, scope: str) -> str:
|
||||
if name not in REGISTRY:
|
||||
return json.dumps({"status": "error", "error": f"Unknown agent '{name}'. Known: {ordered_agents()}"})
|
||||
run_mode = "fix" if str(mode).lower() == "fix" else "check"
|
||||
renderer = _RENDERER
|
||||
seed = _LAST_CHECK.get(name) if run_mode == "fix" else None
|
||||
agent = REGISTRY[name]()
|
||||
result = await agent.run(run_mode, scope or None, MAESTRO_AGENT_MAX_ITER, renderer=renderer, seed_findings=seed)
|
||||
_LAST_RESULTS[name] = result
|
||||
if run_mode == "check":
|
||||
_LAST_CHECK[name] = result["items"]
|
||||
items = result["items"][:MAX_RETURNED_FINDINGS]
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"agent": name,
|
||||
"mode": run_mode,
|
||||
"summary": result["summary"],
|
||||
"incomplete": result.get("incomplete", False),
|
||||
"seeded_from_check": bool(seed),
|
||||
"cost": result.get("cost"),
|
||||
"report": result["json"],
|
||||
"findings": items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def list_agents():
|
||||
"""List the maintenance agents Maestro can run, with their dimension."""
|
||||
return json.dumps(
|
||||
{"status": "success", "agents": [{"name": name, "dimension": REGISTRY[name].description} for name in ordered_agents()]}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
async def read_report(agent: str):
|
||||
"""Return the latest in-memory result for one agent without re-running it.
|
||||
agent: The agent name, e.g. security, audit, devii.
|
||||
"""
|
||||
result = _LAST_RESULTS.get(agent)
|
||||
if result is None:
|
||||
return json.dumps({"status": "error", "error": f"No result yet for '{agent}'. Run it first."})
|
||||
return json.dumps({"status": "success", "agent": agent, "summary": result["summary"], "findings": result["items"][:MAX_RETURNED_FINDINGS]})
|
||||
|
||||
|
||||
@tool
|
||||
async def run_security(mode: str = "check", scope: str = ""):
|
||||
"""Run the data and role security agent. mode: check (default) or fix. scope: optional unit label."""
|
||||
return await _dispatch("security", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_audit(mode: str = "check", scope: str = ""):
|
||||
"""Run the audit-log coverage agent. mode: check (default) or fix. scope: optional unit label."""
|
||||
return await _dispatch("audit", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_devii(mode: str = "check", scope: str = ""):
|
||||
"""Run the Devii capability and role-gated tool-list agent. mode: check (default) or fix."""
|
||||
return await _dispatch("devii", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_docs(mode: str = "check", scope: str = ""):
|
||||
"""Run the documentation coverage and role-aware show/hide agent. mode: check (default) or fix."""
|
||||
return await _dispatch("docs", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_fanout(mode: str = "check", scope: str = ""):
|
||||
"""Run the cross-layer feature completeness agent. mode: check (default) or fix."""
|
||||
return await _dispatch("fanout", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_dry(mode: str = "check", scope: str = ""):
|
||||
"""Run the duplication and reuse agent. mode: check (default) or fix."""
|
||||
return await _dispatch("dry", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_style(mode: str = "check", scope: str = ""):
|
||||
"""Run the coding-rule compliance agent. mode: check (default) or fix."""
|
||||
return await _dispatch("style", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_frontend(mode: str = "check", scope: str = ""):
|
||||
"""Run the ES6, component, and CSS consistency agent. mode: check (default) or fix."""
|
||||
return await _dispatch("frontend", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_seo(mode: str = "check", scope: str = ""):
|
||||
"""Run the SEO and sitemap coverage agent. mode: check (default) or fix."""
|
||||
return await _dispatch("seo", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_tests(mode: str = "check", scope: str = ""):
|
||||
"""Run the integration-test coverage agent. It writes tests but never runs the suite. mode: check (default) or fix."""
|
||||
return await _dispatch("test", mode, scope)
|
||||
|
||||
|
||||
@tool
|
||||
async def run_fleet_tool(mode: str = "check"):
|
||||
"""Run the whole maintenance fleet in dependency order via the orchestrator. mode: check (default) or fix."""
|
||||
run_mode = "fix" if str(mode).lower() == "fix" else "check"
|
||||
code = await run_fleet(run_mode, None, MAESTRO_AGENT_MAX_ITER, renderer=_RENDERER)
|
||||
return json.dumps({"status": "success", "mode": run_mode, "fleet_exit_code": code})
|
||||
|
||||
|
||||
MAESTRO_TOOLS = (
|
||||
"list_agents",
|
||||
"read_report",
|
||||
"run_security",
|
||||
"run_audit",
|
||||
"run_devii",
|
||||
"run_docs",
|
||||
"run_fanout",
|
||||
"run_dry",
|
||||
"run_style",
|
||||
"run_frontend",
|
||||
"run_seo",
|
||||
"run_tests",
|
||||
"run_fleet_tool",
|
||||
"read_file",
|
||||
"read_lines",
|
||||
"grep",
|
||||
"glob_files",
|
||||
"list_dir",
|
||||
"retrieve",
|
||||
"get_current_isodate",
|
||||
)
|
||||
|
||||
MAESTRO_PROMPT = """You are Maestro, the single conversational conductor of the DevPlace maintenance fleet. The operator talks to you and you master the rest: you decide which specialist agent or combination answers a request, run it, and explain the result in plain language.
|
||||
|
||||
THE FLEET (run each via its run_* tool):
|
||||
- run_security: data and role security
|
||||
- run_audit: audit-log coverage
|
||||
- run_devii: Devii capability and role-gated tool list
|
||||
- run_docs: documentation coverage and role-aware show/hide
|
||||
- run_fanout: cross-layer feature completeness
|
||||
- run_dry: duplication and reuse
|
||||
- run_style: coding-rule compliance
|
||||
- run_frontend: ES6, component, and CSS consistency
|
||||
- run_seo: SEO and sitemap coverage
|
||||
- run_tests: integration-test coverage (writes tests, never runs the suite)
|
||||
- run_fleet_tool: the whole fleet in dependency order
|
||||
|
||||
ROUTING: map the request to the right dimension and call its tool. Use list_agents if unsure. For an "everything" request use run_fleet_tool. Clarify an ambiguous request before running anything.
|
||||
|
||||
MODE POLICY: a question defaults to check (read-only). Run fix ONLY when the operator explicitly asks to fix, and CONFIRM before any fix run that writes, especially run_fleet_tool in fix mode. Never silently edit in answer to a question. When the operator asks to fix issues you just found in a check, call the SAME agent's run_* tool with mode=fix exactly once; it is automatically seeded with the confirmed findings and fixes them directly (do not re-run check first, and do not call it more than once).
|
||||
|
||||
HONESTY ABOUT RESULTS (critical): the tool result includes `incomplete` and `seeded_from_check`. The agents DO have full write access (read_file, edit_file, write_file, verify) in fix mode. If a fix run reports `incomplete: true`, it ran out of its iteration budget before finishing - say exactly that and offer to run it again. If it fixed 0 of N seeded findings, report that plainly and show the operator the findings; do NOT invent a reason. NEVER claim you or the agents lack write access or tools - that is false. Never describe a run as clean or successful when `incomplete` is true.
|
||||
|
||||
COST: every run streams its live cost after each AI call (per call and running total, with money emojis) and the tool result returns a `cost` field ({run_usd, session_usd}). When you summarize a run, mention what it cost in dollars so the operator sees how expensive each procedure was.
|
||||
|
||||
EXPLAINING: after a run, summarize the findings in plain language and reference the report path. Use read_report to answer follow-ups without re-running. Use read_file, grep, and retrieve to ground an explanation in the actual source. Every claim you make must be backed by a tool result or a direct source read; never invent a finding.
|
||||
"""
|
||||
|
||||
|
||||
def _payload() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for name in MAESTRO_TOOLS:
|
||||
func = get_tool(name)
|
||||
if func is not None:
|
||||
out.append(func._tool_payload)
|
||||
return out
|
||||
|
||||
|
||||
async def _turn(messages: list[dict[str, Any]], renderer: MarkdownRenderer) -> None:
|
||||
_set_renderer(renderer)
|
||||
state = AgentState()
|
||||
await react_loop(
|
||||
messages=messages,
|
||||
tools_payload=_payload(),
|
||||
state=state,
|
||||
max_iterations=60,
|
||||
renderer=renderer,
|
||||
)
|
||||
|
||||
|
||||
async def interactive(renderer: MarkdownRenderer) -> None:
|
||||
renderer.print("# Maestro\nThe conductor. Ask about any quality dimension, or say `exit` to quit.")
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": _with_datetime(MAESTRO_PROMPT)}]
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
try:
|
||||
line = await loop.run_in_executor(None, lambda: input("\n> "))
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("exit", "quit"):
|
||||
break
|
||||
messages.append({"role": "user", "content": line})
|
||||
await _turn(messages, renderer)
|
||||
|
||||
|
||||
async def _amain(argv: Optional[list[str]] = None) -> int:
|
||||
install_timestamps()
|
||||
parser = argparse.ArgumentParser(prog="agents.maestro", description="Maestro, the conversational maintenance conductor")
|
||||
parser.add_argument("prompt", nargs="?", help="One-shot request; omit for interactive mode")
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
renderer = MarkdownRenderer(use_color=not args.no_color)
|
||||
try:
|
||||
if args.prompt:
|
||||
messages = [
|
||||
{"role": "system", "content": _with_datetime(MAESTRO_PROMPT)},
|
||||
{"role": "user", "content": args.prompt},
|
||||
]
|
||||
await _turn(messages, renderer)
|
||||
else:
|
||||
await interactive(renderer)
|
||||
return 0
|
||||
finally:
|
||||
await close_http_client()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
sys.exit(asyncio.run(_amain()))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
110
agents/orchestrator.py
Normal file
110
agents/orchestrator.py
Normal file
@ -0,0 +1,110 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from . import core
|
||||
from .agent import MarkdownRenderer, close_http_client, cost_session_total, format_usd, install_timestamps, usd_str
|
||||
from .fleet import REGISTRY, ordered_agents
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="agents.orchestrator", description="Run the maintenance fleet")
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--fix", dest="mode", action="store_const", const="fix")
|
||||
mode.add_argument("--check", dest="mode", action="store_const", const="check")
|
||||
parser.set_defaults(mode="fix")
|
||||
parser.add_argument("--only", default=None, help="Comma-separated subset of agent names")
|
||||
parser.add_argument("--max-iter", type=int, default=40)
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
async def run_fleet(mode: str, only: Optional[str], max_iter: int, renderer: Optional[MarkdownRenderer]) -> int:
|
||||
names = ordered_agents(only.split(",") if only else None)
|
||||
started = datetime.now()
|
||||
results: list[dict] = []
|
||||
for name in names:
|
||||
agent = REGISTRY[name]()
|
||||
if renderer is not None:
|
||||
renderer.print(f"\n## {name}")
|
||||
result = await agent.run(mode, None, max_iter, renderer)
|
||||
results.append(
|
||||
{
|
||||
"name": name,
|
||||
"summary": result["summary"],
|
||||
"incomplete": result.get("incomplete", False),
|
||||
"exit_code": result["exit_code"],
|
||||
"json": result["json"],
|
||||
}
|
||||
)
|
||||
finished = datetime.now()
|
||||
|
||||
totals = {"total": 0, "errors": 0, "warnings": 0, "fixed": 0, "unfixed": 0}
|
||||
for result in results:
|
||||
for key in totals:
|
||||
totals[key] += result["summary"][key]
|
||||
exit_code = max((result["exit_code"] for result in results), default=0)
|
||||
|
||||
session_cost = cost_session_total()["cost"]
|
||||
core.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
codename = core.report_codename()
|
||||
stamp = started.strftime("%Y%m%d-%H%M%S")
|
||||
fleet_payload = {
|
||||
"agent": "fleet",
|
||||
"codename": codename,
|
||||
"mode": mode,
|
||||
"started_at": started.isoformat(),
|
||||
"finished_at": finished.isoformat(),
|
||||
"totals": totals,
|
||||
"cost": {"session_usd": usd_str(session_cost)},
|
||||
"agents": results,
|
||||
}
|
||||
fleet_json = core.REPORTS_DIR / f"fleet-{codename}-{stamp}.json"
|
||||
fleet_json.write_text(json.dumps(fleet_payload, indent=2), encoding="utf-8")
|
||||
core.prune_reports("fleet")
|
||||
|
||||
if renderer is not None:
|
||||
rows = "\n".join(
|
||||
f"- {result['name']}: {result['summary']['total']} findings "
|
||||
f"({result['summary']['errors']} err), {result['summary']['fixed']} fixed"
|
||||
for result in results
|
||||
)
|
||||
renderer.print(
|
||||
f"\n# Fleet [{mode}] complete\n{rows}\n\n"
|
||||
f"Totals: {totals['total']} findings, {totals['errors']} errors, {totals['fixed']} fixed. "
|
||||
f"\U0001F4B0 {format_usd(session_cost)} total session cost. "
|
||||
f"Report: `{fleet_json}`"
|
||||
)
|
||||
return exit_code
|
||||
|
||||
|
||||
async def _amain(argv: Optional[list[str]] = None) -> int:
|
||||
install_timestamps()
|
||||
args = _parser().parse_args(argv)
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
renderer = MarkdownRenderer(use_color=not args.no_color)
|
||||
try:
|
||||
return await run_fleet(args.mode, args.only, args.max_iter, renderer)
|
||||
finally:
|
||||
await close_http_client()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
sys.exit(asyncio.run(_amain()))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
agents/reports/seo-20260612-005119.json
Normal file
14
agents/reports/seo-20260612-005119.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"agent": "seo",
|
||||
"mode": "check",
|
||||
"started_at": "2026-06-12T00:51:19.794707",
|
||||
"finished_at": "2026-06-12T00:52:22.621735",
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"fixed": 0,
|
||||
"unfixed": 0
|
||||
},
|
||||
"findings": []
|
||||
}
|
||||
11
agents/reports/seo-20260612-005119.md
Normal file
11
agents/reports/seo-20260612-005119.md
Normal file
@ -0,0 +1,11 @@
|
||||
# seo report (check)
|
||||
|
||||
- total: 0
|
||||
- errors: 0
|
||||
- warnings: 0
|
||||
- fixed: 0
|
||||
- unfixed: 0
|
||||
|
||||
## Findings
|
||||
|
||||
None.
|
||||
18
agents/reports/seo-20260612-013402.json
Normal file
18
agents/reports/seo-20260612-013402.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"agent": "seo",
|
||||
"mode": "check",
|
||||
"started_at": "2026-06-12T01:34:02.164037",
|
||||
"finished_at": "2026-06-12T01:34:16.966318",
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"fixed": 0,
|
||||
"unfixed": 0
|
||||
},
|
||||
"cost": {
|
||||
"run_usd": "0.00151031",
|
||||
"session_usd": "0.00151031"
|
||||
},
|
||||
"findings": []
|
||||
}
|
||||
11
agents/reports/seo-20260612-013402.md
Normal file
11
agents/reports/seo-20260612-013402.md
Normal file
@ -0,0 +1,11 @@
|
||||
# seo report (check)
|
||||
|
||||
- total: 0
|
||||
- errors: 0
|
||||
- warnings: 0
|
||||
- fixed: 0
|
||||
- unfixed: 0
|
||||
|
||||
## Findings
|
||||
|
||||
None.
|
||||
135
agents/reports/style-20260612-005257.json
Normal file
135
agents/reports/style-20260612-005257.json
Normal file
@ -0,0 +1,135 @@
|
||||
{
|
||||
"agent": "style",
|
||||
"mode": "check",
|
||||
"started_at": "2026-06-12T00:52:57.094561",
|
||||
"finished_at": "2026-06-12T00:54:21.002389",
|
||||
"summary": {
|
||||
"total": 12,
|
||||
"errors": 7,
|
||||
"warnings": 5,
|
||||
"fixed": 0,
|
||||
"unfixed": 12
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "devplacepy/services/bot/llm.py",
|
||||
"line": 105,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 30,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in docstring \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 1732,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2217,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in instruction string \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2250,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2293,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/style.py",
|
||||
"line": 38,
|
||||
"rule": "em-dash",
|
||||
"message": "HTML entity — and — used in rule definition \u2014 must use hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "review.md",
|
||||
"line": 1,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used throughout documentation prose in multiple lines",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "notifs.md",
|
||||
"line": 256,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in documentation prose",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "devplace.md",
|
||||
"line": 1,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in documentation prose throughout multiple lines",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "visuals.md",
|
||||
"line": 8,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in documentation prose throughout multiple lines",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "session-ses_1eae.md",
|
||||
"line": 37,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) and — HTML entity used throughout session log prose",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
37
agents/reports/style-20260612-005257.md
Normal file
37
agents/reports/style-20260612-005257.md
Normal file
@ -0,0 +1,37 @@
|
||||
# style report (check)
|
||||
|
||||
- total: 12
|
||||
- errors: 7
|
||||
- warnings: 5
|
||||
- fixed: 0
|
||||
- unfixed: 12
|
||||
|
||||
## Findings
|
||||
|
||||
### agents/agent.py
|
||||
- [error] em-dash:30: Em-dash character (U+2014) used in docstring — must be hyphen instead
|
||||
- [error] em-dash:1732: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
- [error] em-dash:2217: Em-dash character (U+2014) used in instruction string — must be hyphen instead
|
||||
- [error] em-dash:2250: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
- [error] em-dash:2293: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
|
||||
### agents/style.py
|
||||
- [error] em-dash:38: HTML entity — and — used in rule definition — must use hyphen instead
|
||||
|
||||
### devplace.md
|
||||
- [warning] em-dash:1: Em-dash character (U+2014) used in documentation prose throughout multiple lines
|
||||
|
||||
### devplacepy/services/bot/llm.py
|
||||
- [error] em-dash:105: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
|
||||
### notifs.md
|
||||
- [warning] em-dash:256: Em-dash character (U+2014) used in documentation prose
|
||||
|
||||
### review.md
|
||||
- [warning] em-dash:1: Em-dash character (U+2014) used throughout documentation prose in multiple lines
|
||||
|
||||
### session-ses_1eae.md
|
||||
- [warning] em-dash:37: Em-dash character (U+2014) and — HTML entity used throughout session log prose
|
||||
|
||||
### visuals.md
|
||||
- [warning] em-dash:8: Em-dash character (U+2014) used in documentation prose throughout multiple lines
|
||||
495
agents/reports/style-20260612-005746.json
Normal file
495
agents/reports/style-20260612-005746.json
Normal file
@ -0,0 +1,495 @@
|
||||
{
|
||||
"agent": "style",
|
||||
"mode": "check",
|
||||
"started_at": "2026-06-12T00:57:46.956032",
|
||||
"finished_at": "2026-06-12T01:01:12.646284",
|
||||
"summary": {
|
||||
"total": 48,
|
||||
"errors": 13,
|
||||
"warnings": 32,
|
||||
"fixed": 0,
|
||||
"unfixed": 48
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/utils.py",
|
||||
"line": 434,
|
||||
"rule": "forbidden-suffix-_info",
|
||||
"message": "Function name 'badge_info' uses forbidden suffix '_info'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/push.py",
|
||||
"line": 246,
|
||||
"rule": "forbidden-suffix-_info",
|
||||
"message": "Variable name 'notification_info' uses forbidden suffix '_info'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/routers/services.py",
|
||||
"line": 44,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Function name 'services_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/routers/services.py",
|
||||
"line": 84,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Function name 'service_detail_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/routers/containers.py",
|
||||
"line": 112,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Function name 'containers_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/routers/containers_admin.py",
|
||||
"line": 70,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Function name 'containers_index_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/routers/admin.py",
|
||||
"line": 109,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Function name 'admin_ai_usage_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/services/devii/actions/catalog.py",
|
||||
"line": 1046,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Action name 'admin_services_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/routers/gists.py",
|
||||
"line": 91,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Variable name 'gists_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/services/jobs/base.py",
|
||||
"line": 109,
|
||||
"rule": "forbidden-suffix-_data",
|
||||
"message": "Parameter name 'result_data' uses forbidden suffix '_data'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/services/devii/session.py",
|
||||
"line": 264,
|
||||
"rule": "forbidden-prefix-best_",
|
||||
"message": "Variable name 'best_rank' uses forbidden prefix 'best_'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/schemas.py",
|
||||
"line": 156,
|
||||
"rule": "forbidden-prefix-my_",
|
||||
"message": "Field name 'my_choice' uses forbidden prefix 'my_' (public API field in PollOut schema)",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/schemas.py",
|
||||
"line": 265,
|
||||
"rule": "forbidden-prefix-my_",
|
||||
"message": "Field name 'my_vote' uses forbidden prefix 'my_' (public API field repeated across multiple schemas)",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/database.py",
|
||||
"line": 589,
|
||||
"rule": "forbidden-prefix-my_",
|
||||
"message": "Variable name 'my_placeholders' uses forbidden prefix 'my_'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/database.py",
|
||||
"line": 589,
|
||||
"rule": "forbidden-prefix-my_",
|
||||
"message": "Variable name 'my_params' uses forbidden prefix 'my_'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/database.py",
|
||||
"line": 646,
|
||||
"rule": "forbidden-prefix-my_",
|
||||
"message": "Variable name 'my_choice' uses forbidden prefix 'my_'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "forbidden-names",
|
||||
"file": "devplacepy/database.py",
|
||||
"line": 692,
|
||||
"rule": "forbidden-prefix-my_",
|
||||
"message": "Variable name 'my_votes' uses forbidden prefix 'my_'",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/main.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header as first line in devplacepy core module",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/database.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header as first line",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/schemas.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header as first line",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/utils.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header as first line",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/models.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header as first line",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/config.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header as first line",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/routers/admin.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header - many files in devplacepy/routers/ directory missing header",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/services/base.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header - many files in devplacepy/services/ directory missing header",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/services/bot/bot.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header - bot service files missing header",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/services/openai_gateway/gateway.py",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header - gateway service files missing header",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/static/js/ApiDocs.js",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header - many JS files in devplacepy/static/js/ missing header (e.g., ApiDocs.js, ApiKeyManager.js, ApiTester.js, Application.js, etc.)",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/static/css/base.css",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header - many CSS files missing header (e.g., base.css, variables.css, post.css, auth.css, etc.)",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "headers",
|
||||
"file": "devplacepy/templates/base.html",
|
||||
"line": 1,
|
||||
"rule": "missing-retoor-header",
|
||||
"message": "Missing mandatory retoor header - HTML template files missing header",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/cli.py",
|
||||
"line": 7,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function '_audit_cli' missing type annotations for all parameters and return type",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/seo.py",
|
||||
"line": 20,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function 'truncate' missing type annotations for 'text' parameter and return type",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/attachments.py",
|
||||
"line": 109,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function 'allowed_extensions' missing return type annotation",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/templating.py",
|
||||
"line": 21,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function 'guest_disabled' missing type annotations for 'user' parameter and return type",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/templating.py",
|
||||
"line": 27,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function 'login_hint' missing type annotations for 'user' parameter and return type",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/main.py",
|
||||
"line": 97,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function 'init_lock' missing return type annotation",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/database.py",
|
||||
"line": 94,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function '_index' missing type annotations for all parameters",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/database.py",
|
||||
"line": 103,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function 'init_db' missing return type annotation",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/seo.py",
|
||||
"line": 36,
|
||||
"rule": "missing-type-annotations",
|
||||
"message": "Function 'site_url' missing type annotations for 'request' parameter and return type",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "typing",
|
||||
"file": "devplacepy/cli.py",
|
||||
"line": 7,
|
||||
"rule": "missing-type-annotations-bulk",
|
||||
"message": "Many functions in devplacepy/cli.py, seo.py, attachments.py, templating.py, docs_api.py, docs_examples.py, database.py lack full type annotations on parameters and return types",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "pathlib",
|
||||
"file": "devplacepy/services/jobs/zip_worker.py",
|
||||
"line": 22,
|
||||
"rule": "use-pathlib",
|
||||
"message": "Uses os.path.getsize, os.path.join, os.path.relpath, os.path.isdir instead of pathlib.Path equivalents",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "pathlib",
|
||||
"file": "devplacepy/services/bot/registry.py",
|
||||
"line": 47,
|
||||
"rule": "use-pathlib",
|
||||
"message": "Uses os.path.getsize instead of Path.stat().st_size",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "pathlib",
|
||||
"file": "devplacepy/project_files.py",
|
||||
"line": 528,
|
||||
"rule": "use-pathlib",
|
||||
"message": "Uses os.walk instead of Path.rglob or Path.iterdir",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "pathlib",
|
||||
"file": "devplacepy/services/jobs/zip_worker.py",
|
||||
"line": 18,
|
||||
"rule": "use-pathlib",
|
||||
"message": "Uses os.walk instead of pathlib.Path.rglob",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "warning",
|
||||
"dimension": "magic-numbers",
|
||||
"file": "devplacepy/routers/avatar.py",
|
||||
"line": 12,
|
||||
"rule": "magic-number",
|
||||
"message": "Magic number 4096 used as cache max_size; should be a named constant",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "magic-numbers",
|
||||
"file": "devplacepy/attachments.py",
|
||||
"line": 106,
|
||||
"rule": "magic-number",
|
||||
"message": "Magic number 1024*1024 used repeatedly for bytes conversion; should use a named constant like BYTES_PER_MB",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "magic-numbers",
|
||||
"file": "devplacepy/attachments.py",
|
||||
"line": 499,
|
||||
"rule": "magic-number",
|
||||
"message": "Repeated literal 1024 in format_file_size; should use named constant",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "version-pinning",
|
||||
"file": "pyproject.toml",
|
||||
"line": 0,
|
||||
"rule": "no-pinning-convention",
|
||||
"message": "No version pinning found in pyproject.toml - dependencies use unversioned constraints (which is the desired pattern)",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
117
agents/reports/style-20260612-005746.md
Normal file
117
agents/reports/style-20260612-005746.md
Normal file
@ -0,0 +1,117 @@
|
||||
# style report (check)
|
||||
|
||||
- total: 48
|
||||
- errors: 13
|
||||
- warnings: 32
|
||||
- fixed: 0
|
||||
- unfixed: 48
|
||||
|
||||
## Findings
|
||||
|
||||
### devplacepy/attachments.py
|
||||
- [warning] missing-type-annotations:109: Function 'allowed_extensions' missing return type annotation
|
||||
- [info] magic-number:106: Magic number 1024*1024 used repeatedly for bytes conversion; should use a named constant like BYTES_PER_MB
|
||||
- [info] magic-number:499: Repeated literal 1024 in format_file_size; should use named constant
|
||||
|
||||
### devplacepy/cli.py
|
||||
- [warning] missing-type-annotations:7: Function '_audit_cli' missing type annotations for all parameters and return type
|
||||
- [warning] missing-type-annotations-bulk:7: Many functions in devplacepy/cli.py, seo.py, attachments.py, templating.py, docs_api.py, docs_examples.py, database.py lack full type annotations on parameters and return types
|
||||
|
||||
### devplacepy/config.py
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header as first line
|
||||
|
||||
### devplacepy/database.py
|
||||
- [warning] forbidden-prefix-my_:589: Variable name 'my_placeholders' uses forbidden prefix 'my_'
|
||||
- [warning] forbidden-prefix-my_:589: Variable name 'my_params' uses forbidden prefix 'my_'
|
||||
- [warning] forbidden-prefix-my_:646: Variable name 'my_choice' uses forbidden prefix 'my_'
|
||||
- [warning] forbidden-prefix-my_:692: Variable name 'my_votes' uses forbidden prefix 'my_'
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header as first line
|
||||
- [warning] missing-type-annotations:94: Function '_index' missing type annotations for all parameters
|
||||
- [warning] missing-type-annotations:103: Function 'init_db' missing return type annotation
|
||||
|
||||
### devplacepy/main.py
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header as first line in devplacepy core module
|
||||
- [warning] missing-type-annotations:97: Function 'init_lock' missing return type annotation
|
||||
|
||||
### devplacepy/models.py
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header as first line
|
||||
|
||||
### devplacepy/project_files.py
|
||||
- [warning] use-pathlib:528: Uses os.walk instead of Path.rglob or Path.iterdir
|
||||
|
||||
### devplacepy/push.py
|
||||
- [warning] forbidden-suffix-_info:246: Variable name 'notification_info' uses forbidden suffix '_info'
|
||||
|
||||
### devplacepy/routers/admin.py
|
||||
- [warning] forbidden-suffix-_data:109: Function name 'admin_ai_usage_data' uses forbidden suffix '_data'
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header - many files in devplacepy/routers/ directory missing header
|
||||
|
||||
### devplacepy/routers/avatar.py
|
||||
- [warning] magic-number:12: Magic number 4096 used as cache max_size; should be a named constant
|
||||
|
||||
### devplacepy/routers/containers.py
|
||||
- [warning] forbidden-suffix-_data:112: Function name 'containers_data' uses forbidden suffix '_data'
|
||||
|
||||
### devplacepy/routers/containers_admin.py
|
||||
- [warning] forbidden-suffix-_data:70: Function name 'containers_index_data' uses forbidden suffix '_data'
|
||||
|
||||
### devplacepy/routers/gists.py
|
||||
- [warning] forbidden-suffix-_data:91: Variable name 'gists_data' uses forbidden suffix '_data'
|
||||
|
||||
### devplacepy/routers/services.py
|
||||
- [warning] forbidden-suffix-_data:44: Function name 'services_data' uses forbidden suffix '_data'
|
||||
- [warning] forbidden-suffix-_data:84: Function name 'service_detail_data' uses forbidden suffix '_data'
|
||||
|
||||
### devplacepy/schemas.py
|
||||
- [warning] forbidden-prefix-my_:156: Field name 'my_choice' uses forbidden prefix 'my_' (public API field in PollOut schema)
|
||||
- [warning] forbidden-prefix-my_:265: Field name 'my_vote' uses forbidden prefix 'my_' (public API field repeated across multiple schemas)
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header as first line
|
||||
|
||||
### devplacepy/seo.py
|
||||
- [warning] missing-type-annotations:20: Function 'truncate' missing type annotations for 'text' parameter and return type
|
||||
- [warning] missing-type-annotations:36: Function 'site_url' missing type annotations for 'request' parameter and return type
|
||||
|
||||
### devplacepy/services/base.py
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header - many files in devplacepy/services/ directory missing header
|
||||
|
||||
### devplacepy/services/bot/bot.py
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header - bot service files missing header
|
||||
|
||||
### devplacepy/services/bot/registry.py
|
||||
- [warning] use-pathlib:47: Uses os.path.getsize instead of Path.stat().st_size
|
||||
|
||||
### devplacepy/services/devii/actions/catalog.py
|
||||
- [warning] forbidden-suffix-_data:1046: Action name 'admin_services_data' uses forbidden suffix '_data'
|
||||
|
||||
### devplacepy/services/devii/session.py
|
||||
- [warning] forbidden-prefix-best_:264: Variable name 'best_rank' uses forbidden prefix 'best_'
|
||||
|
||||
### devplacepy/services/jobs/base.py
|
||||
- [warning] forbidden-suffix-_data:109: Parameter name 'result_data' uses forbidden suffix '_data'
|
||||
|
||||
### devplacepy/services/jobs/zip_worker.py
|
||||
- [warning] use-pathlib:22: Uses os.path.getsize, os.path.join, os.path.relpath, os.path.isdir instead of pathlib.Path equivalents
|
||||
- [warning] use-pathlib:18: Uses os.walk instead of pathlib.Path.rglob
|
||||
|
||||
### devplacepy/services/openai_gateway/gateway.py
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header - gateway service files missing header
|
||||
|
||||
### devplacepy/static/css/base.css
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header - many CSS files missing header (e.g., base.css, variables.css, post.css, auth.css, etc.)
|
||||
|
||||
### devplacepy/static/js/ApiDocs.js
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header - many JS files in devplacepy/static/js/ missing header (e.g., ApiDocs.js, ApiKeyManager.js, ApiTester.js, Application.js, etc.)
|
||||
|
||||
### devplacepy/templates/base.html
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header - HTML template files missing header
|
||||
|
||||
### devplacepy/templating.py
|
||||
- [warning] missing-type-annotations:21: Function 'guest_disabled' missing type annotations for 'user' parameter and return type
|
||||
- [warning] missing-type-annotations:27: Function 'login_hint' missing type annotations for 'user' parameter and return type
|
||||
|
||||
### devplacepy/utils.py
|
||||
- [warning] forbidden-suffix-_info:434: Function name 'badge_info' uses forbidden suffix '_info'
|
||||
- [error] missing-retoor-header:1: Missing mandatory retoor header as first line
|
||||
|
||||
### pyproject.toml
|
||||
- [info] no-pinning-convention: No version pinning found in pyproject.toml - dependencies use unversioned constraints (which is the desired pattern)
|
||||
95
agents/reports/style-20260612-010202.json
Normal file
95
agents/reports/style-20260612-010202.json
Normal file
@ -0,0 +1,95 @@
|
||||
{
|
||||
"agent": "style",
|
||||
"mode": "check",
|
||||
"started_at": "2026-06-12T01:02:02.351939",
|
||||
"finished_at": "2026-06-12T01:03:54.924366",
|
||||
"summary": {
|
||||
"total": 8,
|
||||
"errors": 8,
|
||||
"warnings": 0,
|
||||
"fixed": 0,
|
||||
"unfixed": 8
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 30,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in docstring \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 1732,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2217,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in instruction string \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2250,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2293,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "devplacepy/services/bot/llm.py",
|
||||
"line": 105,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal as replace target \u2014 must be hyphen or unicode escape instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "tests/test_demo.py.bak",
|
||||
"line": 36,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in comments throughout file \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "tests/test_demo.py.bak",
|
||||
"line": 277,
|
||||
"rule": "em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal \u2014 must be hyphen instead",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
23
agents/reports/style-20260612-010202.md
Normal file
23
agents/reports/style-20260612-010202.md
Normal file
@ -0,0 +1,23 @@
|
||||
# style report (check)
|
||||
|
||||
- total: 8
|
||||
- errors: 8
|
||||
- warnings: 0
|
||||
- fixed: 0
|
||||
- unfixed: 8
|
||||
|
||||
## Findings
|
||||
|
||||
### agents/agent.py
|
||||
- [error] em-dash:30: Em-dash character (U+2014) used in docstring — must be hyphen instead
|
||||
- [error] em-dash:1732: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
- [error] em-dash:2217: Em-dash character (U+2014) used in instruction string — must be hyphen instead
|
||||
- [error] em-dash:2250: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
- [error] em-dash:2293: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
|
||||
### devplacepy/services/bot/llm.py
|
||||
- [error] em-dash:105: Em-dash character (U+2014) used in string literal as replace target — must be hyphen or unicode escape instead
|
||||
|
||||
### tests/test_demo.py.bak
|
||||
- [error] em-dash:36: Em-dash character (U+2014) used in comments throughout file — must be hyphen instead
|
||||
- [error] em-dash:277: Em-dash character (U+2014) used in string literal — must be hyphen instead
|
||||
14
agents/reports/style-20260612-010235.json
Normal file
14
agents/reports/style-20260612-010235.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"agent": "style",
|
||||
"mode": "check",
|
||||
"started_at": "2026-06-12T01:02:35.851370",
|
||||
"finished_at": "2026-06-12T01:05:34.005737",
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"errors": 0,
|
||||
"warnings": 0,
|
||||
"fixed": 0,
|
||||
"unfixed": 0
|
||||
},
|
||||
"findings": []
|
||||
}
|
||||
11
agents/reports/style-20260612-010235.md
Normal file
11
agents/reports/style-20260612-010235.md
Normal file
@ -0,0 +1,11 @@
|
||||
# style report (check)
|
||||
|
||||
- total: 0
|
||||
- errors: 0
|
||||
- warnings: 0
|
||||
- fixed: 0
|
||||
- unfixed: 0
|
||||
|
||||
## Findings
|
||||
|
||||
None.
|
||||
125
agents/reports/style-20260612-010550.json
Normal file
125
agents/reports/style-20260612-010550.json
Normal file
@ -0,0 +1,125 @@
|
||||
{
|
||||
"agent": "style",
|
||||
"mode": "check",
|
||||
"started_at": "2026-06-12T01:05:50.761027",
|
||||
"finished_at": "2026-06-12T01:07:16.324649",
|
||||
"summary": {
|
||||
"total": 11,
|
||||
"errors": 6,
|
||||
"warnings": 0,
|
||||
"fixed": 0,
|
||||
"unfixed": 11
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 30,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used in comment text instead of a hyphen.",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 1732,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used in advice string literal instead of a hyphen.",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2217,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used in comment text instead of a hyphen.",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2250,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used in f-string instead of a hyphen.",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2293,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used in argparse description string instead of a hyphen.",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "em-dash",
|
||||
"file": "devplacepy/services/bot/llm.py",
|
||||
"line": 105,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used in string literal for text.replace() instead of a hyphen or escape sequence.",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "em-dash",
|
||||
"file": "tests/test_demo.py.bak",
|
||||
"line": 36,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Backup test file contains 15 em-dash characters (U+2014) in comments and string literals.",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "em-dash",
|
||||
"file": "review.md",
|
||||
"line": 1,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Documentation file review.md uses em-dash characters throughout (standard English prose, not code).",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "em-dash",
|
||||
"file": "notifs.md",
|
||||
"line": 256,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Documentation file notifs.md uses em-dash characters throughout (standard English prose, not code).",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "em-dash",
|
||||
"file": "devplace.md",
|
||||
"line": 1,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Documentation file devplace.md uses em-dash characters throughout (standard English prose, not code).",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "em-dash",
|
||||
"file": "visuals.md",
|
||||
"line": 8,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Documentation file visuals.md uses em-dash characters throughout (standard English prose, not code).",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
34
agents/reports/style-20260612-010550.md
Normal file
34
agents/reports/style-20260612-010550.md
Normal file
@ -0,0 +1,34 @@
|
||||
# style report (check)
|
||||
|
||||
- total: 11
|
||||
- errors: 6
|
||||
- warnings: 0
|
||||
- fixed: 0
|
||||
- unfixed: 11
|
||||
|
||||
## Findings
|
||||
|
||||
### agents/agent.py
|
||||
- [error] no-em-dash:30: Em-dash character (U+2014) used in comment text instead of a hyphen.
|
||||
- [error] no-em-dash:1732: Em-dash character (U+2014) used in advice string literal instead of a hyphen.
|
||||
- [error] no-em-dash:2217: Em-dash character (U+2014) used in comment text instead of a hyphen.
|
||||
- [error] no-em-dash:2250: Em-dash character (U+2014) used in f-string instead of a hyphen.
|
||||
- [error] no-em-dash:2293: Em-dash character (U+2014) used in argparse description string instead of a hyphen.
|
||||
|
||||
### devplace.md
|
||||
- [info] no-em-dash:1: Documentation file devplace.md uses em-dash characters throughout (standard English prose, not code).
|
||||
|
||||
### devplacepy/services/bot/llm.py
|
||||
- [error] no-em-dash:105: Em-dash character (U+2014) used in string literal for text.replace() instead of a hyphen or escape sequence.
|
||||
|
||||
### notifs.md
|
||||
- [info] no-em-dash:256: Documentation file notifs.md uses em-dash characters throughout (standard English prose, not code).
|
||||
|
||||
### review.md
|
||||
- [info] no-em-dash:1: Documentation file review.md uses em-dash characters throughout (standard English prose, not code).
|
||||
|
||||
### tests/test_demo.py.bak
|
||||
- [info] no-em-dash:36: Backup test file contains 15 em-dash characters (U+2014) in comments and string literals.
|
||||
|
||||
### visuals.md
|
||||
- [info] no-em-dash:8: Documentation file visuals.md uses em-dash characters throughout (standard English prose, not code).
|
||||
85
agents/reports/style-20260612-010804.json
Normal file
85
agents/reports/style-20260612-010804.json
Normal file
@ -0,0 +1,85 @@
|
||||
{
|
||||
"agent": "style",
|
||||
"mode": "fix",
|
||||
"started_at": "2026-06-12T01:08:04.361327",
|
||||
"finished_at": "2026-06-12T01:10:20.066323",
|
||||
"summary": {
|
||||
"total": 7,
|
||||
"errors": 5,
|
||||
"warnings": 0,
|
||||
"fixed": 5,
|
||||
"unfixed": 2
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 30,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used instead of hyphen in docstring",
|
||||
"fixed": true,
|
||||
"fix_summary": "Replaced em-dash with hyphen in docstring about Chrome-accurate header order"
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 1732,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used instead of hyphen in advice string literal",
|
||||
"fixed": true,
|
||||
"fix_summary": "Replaced em-dash with hyphen in plan confidence advice string"
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2217,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used instead of hyphen in SYSTEM_PROMPT string",
|
||||
"fixed": true,
|
||||
"fix_summary": "Replaced em-dash with hyphen in operating protocol text"
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2250,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used instead of hyphen in _with_datetime() f-string",
|
||||
"fixed": true,
|
||||
"fix_summary": "Replaced em-dash with hyphen in datetime message string"
|
||||
},
|
||||
{
|
||||
"severity": "error",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "agents/agent.py",
|
||||
"line": 2293,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash character (U+2014) used instead of hyphen in argparse description",
|
||||
"fixed": true,
|
||||
"fix_summary": "Replaced em-dash with hyphen in argument parser description"
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "devplacepy/services/bot/llm.py",
|
||||
"line": 105,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash characters in llm.py replace() pattern are intentional data processing logic; not changed",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
},
|
||||
{
|
||||
"severity": "info",
|
||||
"dimension": "coding-rule-compliance",
|
||||
"file": "devplacepy/static/uploads/attachments/1a/64/1a640917-f599-4fce-8dc4-2babdb25f716.py",
|
||||
"line": 3,
|
||||
"rule": "no-em-dash",
|
||||
"message": "Em-dash in user-uploaded attachment file; not project source",
|
||||
"fixed": false,
|
||||
"fix_summary": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
27
agents/reports/style-20260612-010804.md
Normal file
27
agents/reports/style-20260612-010804.md
Normal file
@ -0,0 +1,27 @@
|
||||
# style report (fix)
|
||||
|
||||
- total: 7
|
||||
- errors: 5
|
||||
- warnings: 0
|
||||
- fixed: 5
|
||||
- unfixed: 2
|
||||
|
||||
## Findings
|
||||
|
||||
### agents/agent.py
|
||||
- [fixed] no-em-dash:30: Em-dash character (U+2014) used instead of hyphen in docstring
|
||||
fix: Replaced em-dash with hyphen in docstring about Chrome-accurate header order
|
||||
- [fixed] no-em-dash:1732: Em-dash character (U+2014) used instead of hyphen in advice string literal
|
||||
fix: Replaced em-dash with hyphen in plan confidence advice string
|
||||
- [fixed] no-em-dash:2217: Em-dash character (U+2014) used instead of hyphen in SYSTEM_PROMPT string
|
||||
fix: Replaced em-dash with hyphen in operating protocol text
|
||||
- [fixed] no-em-dash:2250: Em-dash character (U+2014) used instead of hyphen in _with_datetime() f-string
|
||||
fix: Replaced em-dash with hyphen in datetime message string
|
||||
- [fixed] no-em-dash:2293: Em-dash character (U+2014) used instead of hyphen in argparse description
|
||||
fix: Replaced em-dash with hyphen in argument parser description
|
||||
|
||||
### devplacepy/services/bot/llm.py
|
||||
- [info] no-em-dash:105: Em-dash characters in llm.py replace() pattern are intentional data processing logic; not changed
|
||||
|
||||
### devplacepy/static/uploads/attachments/1a/64/1a640917-f599-4fce-8dc4-2babdb25f716.py
|
||||
- [info] no-em-dash:3: Em-dash in user-uploaded attachment file; not project source
|
||||
54
agents/security.py
Normal file
54
agents/security.py
Normal file
@ -0,0 +1,54 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class SecurityAgent(MaintenanceAgent):
|
||||
name = "security"
|
||||
description = "data and role security checker"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Guarantee that every state-changing action is correctly authorized, every private resource is gated by "
|
||||
"the single canonical predicate, every file mutation is read-only-guarded, and the input and output "
|
||||
"boundaries are sanitized.\n\n"
|
||||
"DETECT:\n"
|
||||
"- Every @router.post / @router.put / @router.delete has the correct guard: require_user for member writes, "
|
||||
"require_admin for admin writes, or an explicit ownership comparison resource[\"user_uid\"] == user[\"uid\"] "
|
||||
"before edit and delete. A POST with no guard is an error.\n"
|
||||
"- Every private-project read surface flows through content.can_view_project(project, user) and none "
|
||||
"re-implements the owner-or-admin check inline. Surfaces: project detail, project_files._load_viewable_project, "
|
||||
"zip enqueue, listing, profile project list, sitemap.\n"
|
||||
"- Every file-mutating entrypoint in project_files.py calls project_files._guard_writable(project_uid).\n"
|
||||
"- Devii irreversible or destructive actions are present in the dispatcher CONFIRM_REQUIRED set, and "
|
||||
"destructive shell commands match dispatcher.DESTRUCTIVE_COMMAND.\n"
|
||||
"- Input is Pydantic-validated with explicit max lengths (models.py Form models); uploads and downloads are "
|
||||
"slugified; path traversal is blocked with pathlib, never string joins.\n"
|
||||
"- Passwords are hashed with pbkdf2_sha256 via passlib; no plaintext or weak path exists.\n"
|
||||
"- Capability URLs (zip and fork status and download) stay scoped only by the unguessable uuid7.\n"
|
||||
"- The XSS control is intact: DOMPurify.sanitize runs on raw marked output in static/js/components/ContentRenderer.js "
|
||||
"and fails closed; seo.py _json_ld_dumps escapes <, >, & in JSON-LD.\n\n"
|
||||
"FIX: insert the missing guard, route the read through can_view_project, add _guard_writable at the top of the "
|
||||
"mutating function, add the action to the confirm set, add the missing max length or validator, or restore the "
|
||||
"sanitize step. Never weaken a guard to make a finding disappear; a deliberately public read is an info finding."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("routers", "devplacepy/routers/*.py - guard on every POST/PUT/DELETE; ownership before edit/delete"),
|
||||
("project-visibility", "devplacepy/content.py can_view_project used at every private read surface"),
|
||||
("project-files", "devplacepy/project_files.py _guard_writable on every mutating entrypoint"),
|
||||
("devii-confirm", "devplacepy/services/devii/actions/dispatcher.py CONFIRM_REQUIRED and DESTRUCTIVE_COMMAND"),
|
||||
("input-validation", "devplacepy/models.py max lengths; path traversal via pathlib; slugify on upload/download"),
|
||||
("xss", "static/js/components/ContentRenderer.js DOMPurify; devplacepy/seo.py _json_ld_dumps escaping"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(SecurityAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
40
agents/seo.py
Normal file
40
agents/seo.py
Normal file
@ -0,0 +1,40 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class SeoAgent(MaintenanceAgent):
|
||||
name = "seo"
|
||||
description = "SEO and sitemap coverage"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Ensure every public page is correctly described for search and indexed where appropriate.\n\n"
|
||||
"DETECT:\n"
|
||||
"- Every public page builds base_seo_context(request, ...) and merges it into the template response.\n"
|
||||
"- The right JSON-LD schema is emitted (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, "
|
||||
"SoftwareApplication).\n"
|
||||
"- meta_robots is set, and the noindex rules hold (auth, messages, notifications are noindex,nofollow; profiles "
|
||||
"with fewer than two posts are noindex,follow).\n"
|
||||
"- Indexable public pages appear in the routers/seo.py sitemap.\n\n"
|
||||
"FIX: add the missing base_seo_context call, the JSON-LD schema, the robots directive, or the sitemap entry. "
|
||||
"Never index a private or auth-gated page."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("seo-context", "public page routes build seo.base_seo_context"),
|
||||
("json-ld", "the correct JSON-LD schema is emitted per page type"),
|
||||
("robots", "meta_robots set; noindex rules for auth/messages/notifications/thin profiles"),
|
||||
("sitemap", "indexable public pages appear in routers/seo.py sitemap"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(SeoAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
53
agents/style.py
Normal file
53
agents/style.py
Normal file
@ -0,0 +1,53 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class StyleAgent(MaintenanceAgent):
|
||||
name = "style"
|
||||
description = "coding-rule compliance"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Enforce the explicit CLAUDE.md and AGENTS.md coding rules across all source.\n\n"
|
||||
"DETECT (mostly deterministic, grep and AST):\n"
|
||||
"- Forbidden naming prefixes and suffixes: _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.\n"
|
||||
"- No comments or docstrings in source files, EXCEPT the mandatory header and the docstrings that @tool functions "
|
||||
"require for their schema (the proven agent engine convention).\n"
|
||||
"- No em-dash anywhere: neither the em-dash character (U+2014) nor its HTML entities; use a hyphen.\n"
|
||||
"- Full typing coverage on Python function signatures and variables.\n"
|
||||
"- pathlib instead of the os module for paths.\n"
|
||||
"- A fixed-key dict that should be a dataclass.\n"
|
||||
"- No version pinning anywhere (pyproject, requirements, or inline).\n"
|
||||
"- The mandatory `retoor <retoor@molodetz.nl>` header on files you CREATE or are otherwise already editing. "
|
||||
"Do NOT sweep the whole repo adding headers: many pre-existing application files were authored without one, "
|
||||
"and mass-inserting headers into dozens of untouched files is exactly the noise the 'refactor only what you "
|
||||
"touch' rule forbids. If files lack the header, record at most ONE info finding stating the count, and never "
|
||||
"auto-edit a file solely to add a header.\n"
|
||||
"- No magic numbers; named constants instead. No warnings.\n\n"
|
||||
"FIX: rename the symbol to an intent-revealing name, strip the stray comment or docstring, replace the em-dash, "
|
||||
"add the type annotation, convert os.path to pathlib, convert the dict to a dataclass, remove the version pin, add "
|
||||
"the header, or name the constant. Only touch code you are already editing for a finding; do not restyle untouched "
|
||||
"code. A rename that would change a public API symbol is reported, not auto-applied."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("forbidden-names", "devplacepy/**/*.py forbidden naming prefixes/suffixes"),
|
||||
("headers", "retoor header on created/edited files only; one info finding for pre-existing files that lack it, never a mass sweep"),
|
||||
("em-dash", "no em-dash character or its HTML entities in any touched file"),
|
||||
("typing", "Python function signatures and variables fully typed"),
|
||||
("pathlib", "pathlib over the os module; no magic numbers; no version pinning"),
|
||||
("frontend-style", "static/js and static/css naming and constants"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(StyleAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
agents/test_coverage.py
Normal file
37
agents/test_coverage.py
Normal file
@ -0,0 +1,37 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import MaintenanceAgent, cli_main
|
||||
|
||||
|
||||
class TestAgent(MaintenanceAgent):
|
||||
name = "test"
|
||||
description = "integration-test coverage"
|
||||
|
||||
def mandate(self) -> str:
|
||||
return (
|
||||
"Keep integration-test coverage in step with the routes and features, writing tests that follow the project's "
|
||||
"required patterns.\n\n"
|
||||
"DETECT: routes and features with no corresponding test in tests/test_{area}.py. The project prefers integration "
|
||||
"tests over unit tests and tests the interface and API.\n\n"
|
||||
"FIX: write the missing integration test following the required patterns: every page.goto and page.wait_for_url "
|
||||
"passes wait_until=\"domcontentloaded\"; selectors are scoped; a test that flips a global site_settings value "
|
||||
"restores it in try/finally; the shared fixtures (alice, bob, app_server) are used.\n\n"
|
||||
"HARD GUARDRAIL: write tests but NEVER run the suite, not the full suite and not a single file. Validate only by a "
|
||||
"clean import of the new test module (python -c). Never weaken an existing test to make it pass."
|
||||
)
|
||||
|
||||
def scope_units(self) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("coverage-gaps", "routers/*.py routes with no referencing test in tests/test_{area}.py"),
|
||||
("pattern-lint", "tests/*.py use domcontentloaded, scoped selectors, try/finally global restore, shared fixtures"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli_main(TestAgent())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
150
agents/validator.py
Normal file
150
agents/validator.py
Normal file
@ -0,0 +1,150 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import py_compile
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
PYTHON_SUFFIXES = (".py",)
|
||||
JS_SUFFIXES = (".js", ".mjs")
|
||||
CSS_SUFFIXES = (".css",)
|
||||
HTML_SUFFIXES = (".html", ".htm")
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"var",
|
||||
"htmlcov",
|
||||
"vendor",
|
||||
".egg-info",
|
||||
"downloads",
|
||||
}
|
||||
|
||||
|
||||
class Issue:
|
||||
def __init__(self, path: Path, message: str) -> None:
|
||||
self.path = path
|
||||
self.message = message
|
||||
|
||||
|
||||
def _check_python(path: Path) -> Optional[str]:
|
||||
source = path.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
ast.parse(source, filename=str(path))
|
||||
except SyntaxError as error:
|
||||
return f"SyntaxError: {error.msg} (line {error.lineno})"
|
||||
try:
|
||||
py_compile.compile(str(path), doraise=True)
|
||||
except py_compile.PyCompileError as error:
|
||||
return f"CompileError: {error.msg}"
|
||||
return None
|
||||
|
||||
|
||||
def _check_js(path: Path, node: Optional[str]) -> Optional[str]:
|
||||
if node is None:
|
||||
return None
|
||||
result = subprocess.run(
|
||||
[node, "--check", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return f"node --check failed: {result.stderr.strip().splitlines()[-1] if result.stderr.strip() else 'syntax error'}"
|
||||
return None
|
||||
|
||||
|
||||
def _check_braces(path: Path) -> Optional[str]:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
depth = 0
|
||||
for char in text:
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
return "Unbalanced braces: unexpected '}'"
|
||||
if depth != 0:
|
||||
return f"Unbalanced braces: {depth} unclosed '{{'"
|
||||
return None
|
||||
|
||||
|
||||
def _check_html(path: Path) -> Optional[str]:
|
||||
try:
|
||||
import jinja2
|
||||
except ImportError:
|
||||
return None
|
||||
source = path.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
jinja2.Environment(autoescape=True).parse(source)
|
||||
except jinja2.TemplateSyntaxError as error:
|
||||
return f"TemplateSyntaxError: {error.message} (line {error.lineno})"
|
||||
return None
|
||||
|
||||
|
||||
def _iter_files(root: Path):
|
||||
if root.is_file():
|
||||
yield root
|
||||
return
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if any(part in SKIP_DIRS or part.endswith(".egg-info") for part in path.parts):
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def validate(root: Path) -> list[Issue]:
|
||||
node = shutil.which("node")
|
||||
issues: list[Issue] = []
|
||||
for path in _iter_files(root):
|
||||
suffix = path.suffix.lower()
|
||||
message: Optional[str] = None
|
||||
if suffix in PYTHON_SUFFIXES:
|
||||
message = _check_python(path)
|
||||
elif suffix in JS_SUFFIXES:
|
||||
message = _check_js(path, node)
|
||||
elif suffix in CSS_SUFFIXES:
|
||||
message = _check_braces(path)
|
||||
elif suffix in HTML_SUFFIXES:
|
||||
message = _check_html(path)
|
||||
if message is not None:
|
||||
issues.append(Issue(path, message))
|
||||
return issues
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="agents.validator",
|
||||
description="Validate Python, JavaScript, CSS, and HTML without external tools.",
|
||||
)
|
||||
parser.add_argument("path", nargs="?", default=".", help="File or directory to validate")
|
||||
parser.add_argument("-q", "--quiet", action="store_true", help="Only print failures")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
root = Path(args.path)
|
||||
if not root.exists():
|
||||
sys.stderr.write(f"path not found: {root}\n")
|
||||
return 2
|
||||
|
||||
issues = validate(root)
|
||||
if issues:
|
||||
for issue in issues:
|
||||
sys.stderr.write(f"FAIL {issue.path}: {issue.message}\n")
|
||||
sys.stderr.write(f"\n{len(issues)} error(s)\n")
|
||||
return 1
|
||||
if not args.quiet:
|
||||
sys.stdout.write(f"OK: {root} validated clean\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -0,0 +1,2 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from devplacepy.database import get_table
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from os import environ
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
|
||||
REACTION_EMOJI = [
|
||||
|
||||
@ -324,7 +324,7 @@ def enrich_items(
|
||||
user: dict | None = None,
|
||||
) -> list:
|
||||
extra_maps = extra_maps or {}
|
||||
my_votes = (
|
||||
user_votes = (
|
||||
get_user_votes(user["uid"], [item["uid"] for item in items]) if user else {}
|
||||
)
|
||||
enriched = []
|
||||
@ -333,7 +333,7 @@ def enrich_items(
|
||||
key: item,
|
||||
"author": authors.get(item["user_uid"]),
|
||||
"time_ago": time_ago(item[ts_field]),
|
||||
"my_vote": my_votes.get(item["uid"], 0),
|
||||
"my_vote": user_votes.get(item["uid"], 0),
|
||||
}
|
||||
for name, source in extra_maps.items():
|
||||
entry[name] = (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import dataset
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
@ -131,12 +133,33 @@ 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"])
|
||||
attachments = get_table("attachments")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("target_type", ""),
|
||||
("target_uid", ""),
|
||||
("resource_type", ""),
|
||||
("resource_uid", ""),
|
||||
("user_uid", ""),
|
||||
("original_filename", ""),
|
||||
("stored_name", ""),
|
||||
("directory", ""),
|
||||
("file_size", 0),
|
||||
("mime_type", ""),
|
||||
("image_width", 0),
|
||||
("image_height", 0),
|
||||
("has_thumbnail", 0),
|
||||
("thumbnail_name", ""),
|
||||
("created_at", ""),
|
||||
("deleted_at", ""),
|
||||
):
|
||||
if not attachments.has_column(column):
|
||||
attachments.create_column_by_example(column, example)
|
||||
|
||||
_index(
|
||||
db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"]
|
||||
)
|
||||
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
|
||||
if "attachments" in db.tables and not db["attachments"].has_column("deleted_at"):
|
||||
db["attachments"].create_column_by_example("deleted_at", "")
|
||||
_index(db, "attachments", "idx_attachments_user_created", ["user_uid", "created_at"])
|
||||
_index(db, "reactions", "idx_reactions_target", ["target_type", "target_uid"])
|
||||
_index(
|
||||
@ -165,6 +188,24 @@ def init_db():
|
||||
{"uid": f"default_{key}", "key": key, "value": value}
|
||||
)
|
||||
|
||||
news = get_table("news")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("slug", ""),
|
||||
("title", ""),
|
||||
("external_id", ""),
|
||||
("status", ""),
|
||||
("source_name", ""),
|
||||
("url", ""),
|
||||
("description", ""),
|
||||
("content", ""),
|
||||
("synced_at", ""),
|
||||
("grade", 0),
|
||||
("show_on_landing", 0),
|
||||
):
|
||||
if not news.has_column(column):
|
||||
news.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "news", "idx_news_external_id", ["external_id"])
|
||||
_index(db, "news", "idx_news_synced_at", ["synced_at"])
|
||||
_index(db, "news", "idx_news_status", ["status"])
|
||||
@ -175,6 +216,10 @@ def init_db():
|
||||
if not news_images.has_column("url"):
|
||||
news_images.create_column_by_example("url", "")
|
||||
|
||||
news_sync = get_table("news_sync")
|
||||
if not news_sync.has_column("external_id"):
|
||||
news_sync.create_column_by_example("external_id", "")
|
||||
|
||||
_index(db, "news_images", "idx_news_images_news_uid", ["news_uid"])
|
||||
_index(db, "news_sync", "idx_news_sync_external_id", ["external_id"])
|
||||
_index(db, "service_state", "idx_service_state_name", ["name"])
|
||||
@ -586,12 +631,12 @@ def get_reactions_by_targets(target_type, target_uids, user=None):
|
||||
counts[row["target_uid"]][row["emoji"]] = row["c"]
|
||||
mine = defaultdict(list)
|
||||
if user:
|
||||
my_placeholders, my_params = _in_clause(target_uids, prefix="m")
|
||||
my_params["tt"] = target_type
|
||||
my_params["u"] = user["uid"]
|
||||
placeholders, params = _in_clause(target_uids, prefix="m")
|
||||
params["tt"] = target_type
|
||||
params["u"] = user["uid"]
|
||||
for row in db.query(
|
||||
f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({my_placeholders})",
|
||||
**my_params,
|
||||
f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders})",
|
||||
**params,
|
||||
):
|
||||
mine[row["target_uid"]].append(row["emoji"])
|
||||
result = {}
|
||||
@ -643,15 +688,15 @@ def get_polls_by_post_uids(post_uids, user=None):
|
||||
):
|
||||
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
|
||||
totals[row["poll_uid"]] += row["c"]
|
||||
my_choice = {}
|
||||
user_choice = {}
|
||||
if user and "poll_votes" in db.tables:
|
||||
my_placeholders, my_params = _in_clause(poll_uids, prefix="m")
|
||||
my_params["u"] = user["uid"]
|
||||
placeholders, params = _in_clause(poll_uids, prefix="m")
|
||||
params["u"] = user["uid"]
|
||||
for row in db.query(
|
||||
f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({my_placeholders})",
|
||||
**my_params,
|
||||
f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({placeholders})",
|
||||
**params,
|
||||
):
|
||||
my_choice[row["poll_uid"]] = row["option_uid"]
|
||||
user_choice[row["poll_uid"]] = row["option_uid"]
|
||||
options_by_poll = defaultdict(list)
|
||||
for option in options:
|
||||
options_by_poll[option["poll_uid"]].append(option)
|
||||
@ -675,7 +720,7 @@ def get_polls_by_post_uids(post_uids, user=None):
|
||||
"question": poll["question"],
|
||||
"options": rendered,
|
||||
"total": total,
|
||||
"my_choice": my_choice.get(poll_uid),
|
||||
"my_choice": user_choice.get(poll_uid),
|
||||
}
|
||||
return result
|
||||
|
||||
@ -689,7 +734,7 @@ def _build_comment_items(raw, user=None):
|
||||
cids = [c["uid"] for c in raw]
|
||||
users = get_users_by_uids(uids)
|
||||
ups, downs = get_vote_counts(cids)
|
||||
my_votes = get_user_votes(user["uid"], cids) if user else {}
|
||||
user_votes = get_user_votes(user["uid"], cids) if user else {}
|
||||
reactions = get_reactions_by_targets("comment", cids, user)
|
||||
from devplacepy.utils import time_ago
|
||||
from devplacepy.attachments import get_attachments_batch as _gab
|
||||
@ -702,7 +747,7 @@ def _build_comment_items(raw, user=None):
|
||||
"author": users.get(c["user_uid"]),
|
||||
"time_ago": time_ago(c["created_at"]),
|
||||
"votes": {"up": ups.get(c["uid"], 0), "down": downs.get(c["uid"], 0)},
|
||||
"my_vote": my_votes.get(c["uid"], 0),
|
||||
"my_vote": user_votes.get(c["uid"], 0),
|
||||
"children": [],
|
||||
"attachments": atts_map.get(c["uid"], []),
|
||||
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
|
||||
@ -733,7 +778,6 @@ def load_comments(target_type, target_uid, user=None):
|
||||
top.append(item)
|
||||
return top
|
||||
|
||||
|
||||
def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
||||
if not post_uids or "comments" not in db.tables:
|
||||
return {}
|
||||
@ -759,6 +803,41 @@ def get_recent_comments_by_post_uids(post_uids, limit=3, user=None):
|
||||
return dict(result)
|
||||
|
||||
|
||||
def load_comments_by_target_uids(target_type, target_uids, user=None):
|
||||
if not target_uids or "comments" not in db.tables:
|
||||
return {}
|
||||
placeholders, params = _in_clause(target_uids)
|
||||
params["tt"] = target_type
|
||||
raw = list(
|
||||
db.query(
|
||||
f"SELECT * FROM comments WHERE target_type=:tt AND target_uid IN ({placeholders}) ORDER BY created_at",
|
||||
**params,
|
||||
)
|
||||
)
|
||||
if not raw:
|
||||
return {}
|
||||
from collections import defaultdict
|
||||
by_uid = defaultdict(list)
|
||||
for c in raw:
|
||||
by_uid[c["target_uid"]].append(c)
|
||||
result = {}
|
||||
for uid in target_uids:
|
||||
group = by_uid.get(uid, [])
|
||||
if not group:
|
||||
result[uid] = []
|
||||
continue
|
||||
cmap = _build_comment_items(group, user)
|
||||
tree = []
|
||||
for item in cmap.values():
|
||||
parent = item["comment"].get("parent_uid")
|
||||
if parent and parent in cmap:
|
||||
cmap[parent]["children"].append(item)
|
||||
else:
|
||||
tree.append(item)
|
||||
result[uid] = tree
|
||||
return result
|
||||
|
||||
|
||||
def get_attachments(resource_type: str, resource_uid: str) -> list:
|
||||
if "attachments" not in db.tables:
|
||||
return []
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy import schemas
|
||||
from devplacepy.constants import TOPICS, REACTION_EMOJI
|
||||
from devplacepy.docs_examples import schema_example, action_example
|
||||
@ -285,6 +287,143 @@ administrator.
|
||||
"endpoints": [],
|
||||
},
|
||||
{
|
||||
"slug": "auth",
|
||||
"title": "Authentication",
|
||||
"intro": """
|
||||
# Authentication
|
||||
|
||||
Create an account, sign in, recover your password, and log out. These are the only endpoints
|
||||
that set or clear the `session` cookie. All other requests authenticate via that cookie, an
|
||||
`X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials - see
|
||||
[Conventions & Errors](conventions.html) and [Authentication](/docs/authentication.html).
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes).
|
||||
|
||||
## Page vs. action
|
||||
|
||||
The GET endpoints render HTML sign-up, login, and password-reset forms; they also return the
|
||||
page data as JSON when requested with `Accept: application/json` (including `page` to
|
||||
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 valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
|
||||
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
id="auth-signup",
|
||||
method="GET",
|
||||
path="/auth/signup",
|
||||
title="Sign up page",
|
||||
summary="Render the registration form. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="auth-signup-post",
|
||||
method="POST",
|
||||
path="/auth/signup",
|
||||
title="Sign up",
|
||||
summary="Create a new account. Sets the session cookie on success.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
destructive=False,
|
||||
params=[
|
||||
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(
|
||||
id="auth-login",
|
||||
method="GET",
|
||||
path="/auth/login",
|
||||
title="Log in page",
|
||||
summary="Render the login form. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("next", "query", "string", False, "", "Redirect target after login."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-login-post",
|
||||
method="POST",
|
||||
path="/auth/login",
|
||||
title="Log in",
|
||||
summary="Authenticate with username and password. Sets the session cookie.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Your username."),
|
||||
field("password", "form", "string", True, "mysecret", "Your password."),
|
||||
field("next", "form", "string", False, "", "Redirect target after login."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-forgot-password",
|
||||
method="GET",
|
||||
path="/auth/forgot-password",
|
||||
title="Forgot password page",
|
||||
summary="Render the forgot-password form. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="auth-forgot-password-post",
|
||||
method="POST",
|
||||
path="/auth/forgot-password",
|
||||
title="Request password reset",
|
||||
summary="Send a password-reset email with a one-time link.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-reset-password",
|
||||
method="GET",
|
||||
path="/auth/reset-password/{token}",
|
||||
title="Reset password page",
|
||||
summary="Render the password-reset form (only valid with a one-time token). Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-reset-password-post",
|
||||
method="POST",
|
||||
path="/auth/reset-password/{token}",
|
||||
title="Reset password",
|
||||
summary="Set a new password using a one-time reset token.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
|
||||
field("password", "form", "string", True, "newpass", "New password, 6+ characters."),
|
||||
field("confirm_password", "form", "string", True, "newpass", "Must match password."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="auth-logout",
|
||||
method="GET",
|
||||
path="/auth/logout",
|
||||
title="Log out",
|
||||
summary="Clear the session cookie and redirect to the landing page.",
|
||||
auth="public",
|
||||
interactive=False,
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "lookups",
|
||||
"slug": "lookups",
|
||||
"title": "Search & Lookups",
|
||||
"intro": """
|
||||
@ -2547,6 +2686,7 @@ sign requests.
|
||||
{
|
||||
"slug": "containers",
|
||||
"title": "Container Manager",
|
||||
"admin": True,
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
@ -2787,6 +2927,207 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
),
|
||||
],
|
||||
),
|
||||
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."
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -3443,11 +3784,47 @@ four ways to sign requests.
|
||||
"Accepts every field on the admin settings form; only non-empty values are written."
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-reset-ai-quota",
|
||||
method="POST",
|
||||
path="/admin/users/{uid}/reset-ai-quota",
|
||||
title="Reset user AI quota",
|
||||
summary="Delete a specific user's AI gateway ledger rows, resetting their quota.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "USER_UID", "Target user UID."
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-ai-quota-reset-guests",
|
||||
method="POST",
|
||||
path="/admin/ai-quota/reset-guests",
|
||||
title="Reset guest AI quotas",
|
||||
summary="Reset AI quota for all anonymous guest users.",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
endpoint(
|
||||
id="admin-ai-quota-reset-all",
|
||||
method="POST",
|
||||
path="/admin/ai-quota/reset-all",
|
||||
title="Reset all AI quotas",
|
||||
summary="Reset AI quota for every user (members and guests).",
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
_PAGE_RESPONSES = {
|
||||
"auth-signup": schemas.AuthPageOut,
|
||||
"auth-login": schemas.AuthPageOut,
|
||||
"auth-forgot-password": schemas.AuthPageOut,
|
||||
"auth-reset-password": schemas.AuthPageOut,
|
||||
"feed-list": schemas.FeedOut,
|
||||
"posts-detail": schemas.PostDetailOut,
|
||||
"projects-list": schemas.ProjectsOut,
|
||||
@ -3470,6 +3847,11 @@ _PAGE_RESPONSES = {
|
||||
}
|
||||
|
||||
_ACTION_RESPONSES = {
|
||||
"auth-signup-post": ("/feed", {"username": "alice"}),
|
||||
"auth-login-post": ("/feed", None),
|
||||
"auth-forgot-password-post": ("/auth/forgot-password?sent=1", None),
|
||||
"auth-reset-password-post": ("/auth/login", None),
|
||||
"auth-logout": ("/", None),
|
||||
"posts-create": (
|
||||
"/posts/POST_SLUG",
|
||||
{"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from typing import Any, Union, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.database import (
|
||||
db,
|
||||
get_table,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
@ -23,8 +25,8 @@ from devplacepy.database import (
|
||||
get_int_setting,
|
||||
)
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.responses import wants_json, json_error
|
||||
from devplacepy.schemas import ValidationErrorOut
|
||||
from devplacepy.responses import respond, wants_json, json_error
|
||||
from devplacepy.schemas import LandingOut, ValidationErrorOut
|
||||
from fastapi.responses import JSONResponse
|
||||
from devplacepy.utils import get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
@ -484,7 +486,7 @@ async def landing(request: Request):
|
||||
breadcrumbs=[],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
return respond(
|
||||
request,
|
||||
"landing.html",
|
||||
{
|
||||
@ -493,4 +495,5 @@ async def landing(request: Request):
|
||||
"landing_articles": landing_articles,
|
||||
"landing_posts": landing_posts,
|
||||
},
|
||||
model=LandingOut,
|
||||
)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@ -525,7 +524,7 @@ def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -
|
||||
raise ProjectFileError("source directory does not exist")
|
||||
skip = set(skip_names) if skip_names is not None else set(IMPORT_SKIP_NAMES)
|
||||
imported = 0
|
||||
for root, dirs, files in os.walk(src):
|
||||
for root, dirs, files in src.walk():
|
||||
dirs[:] = [d for d in sorted(dirs) if d not in skip]
|
||||
for name in sorted(files):
|
||||
if name in skip:
|
||||
|
||||
@ -243,15 +243,15 @@ async def notify_user(user_uid: str, payload: dict[str, Any]) -> None:
|
||||
for subscription in registrations:
|
||||
endpoint = subscription["endpoint"]
|
||||
try:
|
||||
notification_info = create_notification_info_with_payload(
|
||||
notification_payload = create_notification_info_with_payload(
|
||||
endpoint,
|
||||
subscription["key_auth"],
|
||||
subscription["key_p256dh"],
|
||||
body,
|
||||
)
|
||||
headers = {**notification_info["headers"], "TTL": PUSH_TTL_SECONDS}
|
||||
headers = {**notification_payload["headers"], "TTL": PUSH_TTL_SECONDS}
|
||||
response = await client.post(
|
||||
endpoint, headers=headers, content=notification_info["data"]
|
||||
endpoint, headers=headers, content=notification_payload["data"]
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Push error for %s via %s: %s", user_uid, endpoint, exc)
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated
|
||||
@ -85,6 +87,7 @@ async def admin_ai_usage(request: Request):
|
||||
request,
|
||||
title="AI usage - Admin",
|
||||
description="AI gateway token usage, cost, latency, and reliability metrics.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
@ -149,6 +152,7 @@ async def admin_users(request: Request, page: int = 1):
|
||||
request,
|
||||
title="Users - Admin",
|
||||
description="Manage DevPlace users.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
@ -316,6 +320,7 @@ async def admin_media(request: Request, page: int = 1):
|
||||
request,
|
||||
title="Media - Admin",
|
||||
description="Restore or permanently remove soft-deleted media.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
@ -392,6 +397,7 @@ async def admin_settings(request: Request):
|
||||
request,
|
||||
title="Settings - Admin",
|
||||
description="Manage DevPlace site settings.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
@ -461,6 +467,7 @@ async def admin_audit_log(
|
||||
request,
|
||||
title="Audit Log - Admin",
|
||||
description="Platform audit trail of every state-changing action.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
@ -509,6 +516,7 @@ async def admin_audit_event(request: Request, uid: str):
|
||||
request,
|
||||
title="Audit Event - Admin",
|
||||
description="A single audit event with related objects.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
@ -563,6 +571,7 @@ async def admin_news(request: Request, page: int = 1):
|
||||
request,
|
||||
title="News - Admin",
|
||||
description="Manage DevPlace news articles.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
@ -360,10 +362,14 @@ async def reset_password(
|
||||
errors.append("Invalid or expired reset token")
|
||||
if wants_json(request):
|
||||
return json_error(400, errors[0], errors=errors)
|
||||
seo_ctx = base_seo_context(
|
||||
request, title="Set New Password", robots="noindex,nofollow"
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"reset_password.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"token": token,
|
||||
"errors": errors,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.models import BugForm
|
||||
from devplacepy.database import get_table, load_comments
|
||||
from devplacepy.database import get_table, load_comments_by_target_uids
|
||||
from devplacepy.attachments import get_attachments_batch, link_attachments
|
||||
from devplacepy.utils import (
|
||||
generate_uid,
|
||||
@ -13,7 +15,7 @@ from devplacepy.utils import (
|
||||
get_current_user,
|
||||
create_mention_notifications,
|
||||
)
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import BugsOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
@ -33,20 +35,22 @@ async def bugs_page(request: Request):
|
||||
uids = [b["user_uid"] for b in all_bugs]
|
||||
users_map = get_users_by_uids(uids)
|
||||
|
||||
bug_list = []
|
||||
bug_uids = [b["uid"] for b in all_bugs]
|
||||
attachments_map = get_attachments_batch("bug", bug_uids) if bug_uids else {}
|
||||
comments_map = load_comments_by_target_uids("bug", bug_uids, user) if bug_uids else {}
|
||||
bug_list = []
|
||||
for b in all_bugs:
|
||||
bug_list.append(
|
||||
{
|
||||
"bug": b,
|
||||
"author": users_map.get(b["user_uid"]),
|
||||
"time_ago": time_ago(b["created_at"]),
|
||||
"comments": load_comments("bug", b["uid"], user),
|
||||
"comments": comments_map.get(b["uid"], []),
|
||||
"attachments": attachments_map.get(b["uid"], []),
|
||||
}
|
||||
)
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Bug Reports",
|
||||
@ -55,6 +59,7 @@ async def bugs_page(request: Request):
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Bug Reports", "url": "/bugs"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.seo import site_url
|
||||
from devplacepy.seo import base_seo_context, site_url
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import (
|
||||
@ -17,6 +17,7 @@ from devplacepy.utils import (
|
||||
_user_from_session,
|
||||
get_current_user,
|
||||
is_admin,
|
||||
require_user,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
@ -71,14 +72,19 @@ def _owner_from_request(request: Request):
|
||||
@router.get("/")
|
||||
async def devii_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Devii",
|
||||
description="Your personal AI development assistant on DevPlace.",
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
response = templates.TemplateResponse(
|
||||
request,
|
||||
"devii.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"page_title": "Devii",
|
||||
"meta_robots": "noindex,nofollow",
|
||||
},
|
||||
)
|
||||
if not user and not request.cookies.get(GUEST_COOKIE):
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
@ -22,6 +24,7 @@ SECTION_SERVICES = "Services"
|
||||
SECTION_ARCH = "Architecture"
|
||||
SECTION_TESTING = "Testing"
|
||||
SECTION_PROD = "Production"
|
||||
SECTION_MAINTENANCE = "Maintenance agents"
|
||||
|
||||
DOCS_PAGES = [
|
||||
# General - how to use the site and Devii (everyone)
|
||||
@ -44,6 +47,19 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
# Maintenance agents - the self-maintaining quality fleet (public)
|
||||
{
|
||||
"slug": "maintenance-agents",
|
||||
"title": "Maintenance agents",
|
||||
"kind": "prose",
|
||||
"section": SECTION_MAINTENANCE,
|
||||
},
|
||||
{
|
||||
"slug": "maintenance-usage",
|
||||
"title": "Running the agents",
|
||||
"kind": "prose",
|
||||
"section": SECTION_MAINTENANCE,
|
||||
},
|
||||
# Components - custom HTML web components with live examples (everyone)
|
||||
{
|
||||
"slug": "components",
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
@ -88,7 +90,7 @@ async def gists_page(
|
||||
request: Request, language: str = None, user_uid: str = None, before: str = None
|
||||
):
|
||||
user = get_current_user(request)
|
||||
gists_data, next_cursor, total_count = get_gists_list(
|
||||
gists_list, next_cursor, total_count = get_gists_list(
|
||||
user_uid, language, before, viewer=user
|
||||
)
|
||||
seo_ctx = list_page_seo(
|
||||
@ -108,7 +110,7 @@ async def gists_page(
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"gists": gists_data,
|
||||
"gists": gists_list,
|
||||
"total_count": total_count,
|
||||
"next_cursor": next_cursor,
|
||||
"current_language": language,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.attachments import soft_delete_attachment, restore_attachment
|
||||
from devplacepy.utils import require_user, require_admin, is_admin, not_found
|
||||
from devplacepy.responses import action_result, wants_json, json_error
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -26,11 +29,32 @@ async def delete_media(request: Request, uid: str):
|
||||
if not attachment:
|
||||
raise not_found("Media not found")
|
||||
if attachment.get("user_uid") != user["uid"] and not is_admin(user):
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.delete",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="attachment",
|
||||
target_uid=uid,
|
||||
target_label=attachment.get("filename"),
|
||||
summary=f"user {user['username']} denied delete of attachment {uid}",
|
||||
links=[audit.attachment_link(uid, attachment.get("filename"))],
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return action_result(request, _media_redirect(request, attachment))
|
||||
soft_delete_attachment(uid)
|
||||
logger.info(f"Media {uid} soft-deleted by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.delete",
|
||||
user=user,
|
||||
target_type="attachment",
|
||||
target_uid=uid,
|
||||
target_label=attachment.get("filename"),
|
||||
summary=f"user {user['username']} deleted attachment {attachment.get('filename') or uid}",
|
||||
links=[audit.attachment_link(uid, attachment.get("filename"))],
|
||||
)
|
||||
return action_result(request, _media_redirect(request, attachment))
|
||||
|
||||
|
||||
@ -39,4 +63,14 @@ async def restore_media(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
restore_attachment(uid)
|
||||
logger.info(f"Media {uid} restored by {admin['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.restore",
|
||||
user=admin,
|
||||
target_type="attachment",
|
||||
target_uid=uid,
|
||||
target_label=uid,
|
||||
summary=f"admin {admin['username']} restored attachment {uid}",
|
||||
links=[audit.attachment_link(uid)],
|
||||
)
|
||||
return action_result(request, "/admin/media")
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
|
||||
@ -64,7 +64,9 @@ def _files_url(project: dict) -> str:
|
||||
return f"/projects/{project['slug'] or project['uid']}/files"
|
||||
|
||||
|
||||
def _deny(request: Request, project: dict):
|
||||
def _deny(request: Request, project: dict, user=None, event_key=None, path=""):
|
||||
if event_key and user:
|
||||
_audit_file(request, project, user, event_key, path, result="denied")
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url=_files_url(project), status_code=302)
|
||||
@ -182,7 +184,7 @@ async def project_file_write(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.write", data.path)
|
||||
existed = project_files.get_node(project["uid"], data.path) is not None
|
||||
write_event = "file.write.overwrite" if existed else "file.write.create"
|
||||
try:
|
||||
@ -220,7 +222,7 @@ async def project_file_replace_lines(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.replace_lines", data.path)
|
||||
return _edit(
|
||||
request,
|
||||
project,
|
||||
@ -242,7 +244,7 @@ async def project_file_insert_lines(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.insert_lines", data.path)
|
||||
return _edit(
|
||||
request,
|
||||
project,
|
||||
@ -264,7 +266,7 @@ async def project_file_delete_lines(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.delete_lines", data.path)
|
||||
return _edit(
|
||||
request,
|
||||
project,
|
||||
@ -284,7 +286,7 @@ async def project_file_append(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.append", data.path)
|
||||
return _edit(
|
||||
request,
|
||||
project,
|
||||
@ -299,7 +301,7 @@ async def project_file_upload(request: Request, project_slug: str):
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.upload", "")
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
if not file or not hasattr(file, "filename") or not file.filename:
|
||||
@ -329,7 +331,7 @@ async def project_file_mkdir(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "dir.create", data.path)
|
||||
try:
|
||||
node = project_files.make_dir(project["uid"], user, data.path)
|
||||
except ProjectFileError as exc:
|
||||
@ -347,7 +349,7 @@ async def project_file_move(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.move", data.from_path)
|
||||
try:
|
||||
node = project_files.move_node(
|
||||
project["uid"], user, data.from_path, data.to_path
|
||||
@ -380,7 +382,7 @@ async def project_file_delete(
|
||||
user = require_user(request)
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
return _deny(request, project, user, "file.delete", data.path)
|
||||
try:
|
||||
project_files.delete_node(project["uid"], data.path)
|
||||
except ProjectFileError as exc:
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from sqlalchemy import or_
|
||||
@ -89,13 +91,13 @@ def get_projects_list(
|
||||
|
||||
if page:
|
||||
users_map = get_users_by_uids([p["user_uid"] for p in page])
|
||||
my_votes = (
|
||||
user_votes = (
|
||||
get_user_votes(viewer["uid"], [p["uid"] for p in page]) if viewer else {}
|
||||
)
|
||||
for p in page:
|
||||
author = users_map.get(p["user_uid"])
|
||||
p["author_name"] = author["username"] if author else "Unknown"
|
||||
p["my_vote"] = my_votes.get(p["uid"], 0)
|
||||
p["my_vote"] = user_votes.get(p["uid"], 0)
|
||||
|
||||
return page, next_cursor, total
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
@ -20,6 +22,7 @@ async def services_page(request: Request):
|
||||
request,
|
||||
title="Services - Admin",
|
||||
description="Monitor and configure background services on DevPlace.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
"""Pydantic response models for JSON content negotiation.
|
||||
|
||||
Every model uses ``extra="ignore"`` so it can be validated directly against the
|
||||
dict context a template already receives, dropping internal keys (request, SEO)
|
||||
and - importantly - sensitive user fields (email, api_key, password_hash) that
|
||||
are never part of ``UserOut``.
|
||||
"""
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -428,6 +422,7 @@ class PostDetailOut(_Out):
|
||||
poll: Optional[PollOut] = None
|
||||
comment_count: Optional[int] = None
|
||||
related_posts: list[FeedItemOut] = []
|
||||
topics: list[str] = []
|
||||
|
||||
|
||||
class ProjectsOut(_Out):
|
||||
@ -463,6 +458,8 @@ class GistsOut(_Out):
|
||||
total_count: Optional[int] = None
|
||||
next_cursor: Optional[str] = None
|
||||
current_language: Optional[str] = None
|
||||
languages: list[tuple[str, str]] = []
|
||||
gist_language_codes: list[str] = []
|
||||
|
||||
|
||||
class GistDetailOut(_Out):
|
||||
@ -534,6 +531,9 @@ class ProfileOut(_Out):
|
||||
follow_pagination: Optional[Any] = None
|
||||
followers_count: Optional[int] = None
|
||||
following_count: Optional[int] = None
|
||||
viewer_is_admin: bool = False
|
||||
media: list[dict] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
|
||||
@ -646,6 +646,33 @@ class AuthPageOut(_Out):
|
||||
errors: list = []
|
||||
|
||||
|
||||
class LandingArticleOut(_Out):
|
||||
uid: str = ""
|
||||
slug: str = ""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
source_name: Optional[str] = None
|
||||
grade: int = 0
|
||||
synced_at: Optional[str] = None
|
||||
time_ago: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
class LandingPostOut(_Out):
|
||||
post: Optional[Any] = None
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
comment_count: int = 0
|
||||
stars: int = 0
|
||||
slug: str = ""
|
||||
|
||||
|
||||
class LandingOut(_Out):
|
||||
landing_articles: list[LandingArticleOut] = []
|
||||
landing_posts: list[LandingPostOut] = []
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@ -328,6 +330,7 @@ def _build_sitemap(base_url):
|
||||
urlset.append(
|
||||
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
||||
)
|
||||
urlset.append(url_element(f"{base_url}/bugs", changefreq="daily", priority="0.6"))
|
||||
|
||||
if "posts" in db.tables:
|
||||
posts = _collect(
|
||||
@ -416,6 +419,56 @@ def _build_sitemap(base_url):
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from devplacepy.docs_api import API_GROUPS
|
||||
|
||||
for group in API_GROUPS:
|
||||
if group.get("admin"):
|
||||
continue
|
||||
urlset.append(
|
||||
url_element(
|
||||
f"{base_url}/docs/{group['slug']}.html",
|
||||
changefreq="weekly",
|
||||
priority="0.5",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("sitemap: could not add API docs pages")
|
||||
|
||||
_public_docs_pages = [
|
||||
"index",
|
||||
"devii",
|
||||
"media-gallery",
|
||||
"maintenance-agents",
|
||||
"maintenance-usage",
|
||||
"components",
|
||||
"component-dp-avatar",
|
||||
"component-dp-code",
|
||||
"component-dp-content",
|
||||
"component-dp-upload",
|
||||
"component-dp-toast",
|
||||
"component-dp-dialog",
|
||||
"component-dp-context-menu",
|
||||
"component-dp-lightbox",
|
||||
"component-devii-terminal",
|
||||
"component-devii-avatar",
|
||||
"component-emoji-picker",
|
||||
"styles",
|
||||
"styles-colors",
|
||||
"styles-layout",
|
||||
"styles-responsiveness",
|
||||
"styles-consistency",
|
||||
"authentication",
|
||||
]
|
||||
for slug in _public_docs_pages:
|
||||
urlset.append(
|
||||
url_element(
|
||||
f"{base_url}/docs/{slug}.html",
|
||||
changefreq="weekly",
|
||||
priority="0.5",
|
||||
)
|
||||
)
|
||||
|
||||
rough = tostring(urlset, encoding="unicode")
|
||||
dom = minidom.parseString(rough)
|
||||
return dom.toprettyxml(indent=" ")
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.base import BaseService
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.bot.service import BotsService
|
||||
|
||||
__all__ = ["BotsService"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user