chore: add agents/reports to gitignore and update agent infrastructure with timestamp streaming, write budget, and codename generation

- Add `agents/reports/` to `.gitignore` to prevent generated agent report files from being tracked
- Implement `_TimestampStream` class and `install_timestamps()` function in `agents/agent.py` for prefixing stdout/stderr with timestamps and elapsed time
- Export `install_timestamps`, `set_write_budget`, `clear_write_budget`, and `set_shell_restricted` from `agents/base.py`; add `_RUN_LOCK`, `AGENT_ICONS` dictionary, and `WRITE_BUDGET = 20` constant
- Add `report_codename()` function and `CODENAME_ADJECTIVES`/`CODENAME_ANIMALS` tuples to `agents/core/__init__.py` for generating random agent report codenames
- Expand `WRITE_TOOLS` tuple in `agents/core/__init__.py` to include `replace_lines`, `insert_lines`, and `delete_lines`; remove `SWARM_TOOLS` from `payloads_for` exclusion list
- Update `CLAUDE.md` and `AGENTS.md` documentation with dataset column initialization rules and new agent infrastructure details
- Reorder route table in `README.md` to list `/uploads` before `/messages` and document Swagger/ReDoc/OpenAPI schema endpoints
This commit is contained in:
2026-06-12 03:37:12 +00:00
parent 7fc9b0f715
commit 31841fece4
80 changed files with 2095 additions and 323 deletions
+2 -2
View File
@@ -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] = (
+94 -17
View File
@@ -133,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(
@@ -167,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"])
@@ -177,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"])
@@ -588,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 = {}
@@ -645,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)
@@ -677,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
@@ -691,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
@@ -704,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": []}),
@@ -735,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 {}
@@ -761,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 []
+380
View File
@@ -287,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": """
@@ -2549,6 +2686,7 @@ sign requests.
{
"slug": "containers",
"title": "Container Manager",
"admin": True,
"intro": """
# Container Manager
@@ -2789,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."
),
],
),
],
},
{
@@ -3445,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,
@@ -3472,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"},
+4 -3
View File
@@ -25,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
@@ -486,7 +486,7 @@ async def landing(request: Request):
breadcrumbs=[],
schemas=[website_schema(base)],
)
return templates.TemplateResponse(
return respond(
request,
"landing.html",
{
@@ -495,4 +495,5 @@ async def landing(request: Request):
"landing_articles": landing_articles,
"landing_posts": landing_posts,
},
model=LandingOut,
)
+1 -2
View File
@@ -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:
+3 -3
View File
@@ -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)
+7
View File
@@ -87,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"},
@@ -151,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"},
@@ -318,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"},
@@ -394,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"},
@@ -463,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"},
@@ -511,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"},
@@ -565,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"},
+4
View File
@@ -362,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,
+7 -4
View File
@@ -6,7 +6,7 @@ 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,
@@ -15,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
@@ -35,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",
@@ -57,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,
+9 -3
View File
@@ -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):
+2 -2
View File
@@ -90,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(
@@ -110,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,
+32
View File
@@ -6,6 +6,7 @@ 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()
@@ -28,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))
@@ -41,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")
+12 -10
View File
@@ -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:
+2 -2
View File
@@ -91,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
View File
@@ -22,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"},
+33
View File
@@ -422,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):
@@ -457,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):
@@ -528,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
@@ -640,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()
+51
View File
@@ -330,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(
@@ -418,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=" ")
@@ -94,6 +94,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/feed",
summary="View the activity feed",
requires_auth=False,
params=(
query("tab", "Feed tab to view."),
query("topic", "Filter by topic."),
@@ -123,6 +124,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/posts/{post_slug}",
summary="View a single post by slug",
requires_auth=False,
params=(
path(
"post_slug",
@@ -191,6 +193,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/projects",
summary="List projects",
requires_auth=False,
params=(
query("tab", "Projects tab."),
query("search", "Search query."),
@@ -204,6 +207,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/projects/{project_slug}",
summary="View a project by slug",
requires_auth=False,
params=(
path(
"project_slug",
@@ -489,6 +493,22 @@ ACTIONS: tuple[Action, ...] = (
params=(path("project_slug", "Project slug or uid."),),
requires_auth=False,
),
Action(
name="project_files_zip",
method="POST",
path="/projects/{project_slug}/files/zip",
summary="Build a downloadable zip archive of a project subdirectory",
description=(
"Queues a background zip job for a subpath inside the project and returns "
"{uid, status_url}. Poll the status_url with zip_status until status is 'done', "
"then give the user the download_url. When path is empty the whole project is zipped."
),
params=(
path("project_slug", "Project slug or uid."),
query("path", "Relative subpath inside the project to zip; empty for the whole project."),
),
requires_auth=False,
),
Action(
name="zip_status",
method="GET",
@@ -540,6 +560,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/profile/{username}",
summary="View a user profile",
requires_auth=False,
params=(
path("username", "Username to view."),
query("tab", "Profile tab."),
@@ -566,6 +587,13 @@ ACTIONS: tuple[Action, ...] = (
"Returns the new api_key. Warning: this immediately invalidates any key currently "
"used for authentication, so confirm with the user before calling it."
),
params=(
body(
"confirm",
"Must be true, set only after the user has explicitly confirmed.",
required=True,
),
),
),
Action(
name="list_messages",
@@ -607,6 +635,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/notifications/counts",
summary="Get unread notification and message counts",
requires_auth=False,
),
Action(
name="open_notification",
@@ -763,12 +792,14 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/leaderboard",
summary="View the leaderboard",
requires_auth=False,
),
Action(
name="list_bugs",
method="GET",
path="/bugs",
summary="List reported bugs",
requires_auth=False,
),
Action(
name="create_bug",
@@ -786,6 +817,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/gists",
summary="List gists",
requires_auth=False,
params=(
query("language", "Filter by language."),
query("user_uid", "Filter by owner uid."),
@@ -797,6 +829,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/gists/{gist_slug}",
summary="View a gist by slug",
requires_auth=False,
params=(
path(
"gist_slug",
@@ -850,6 +883,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/news",
summary="List news articles",
requires_auth=False,
params=(query("before", "Pagination cursor."),),
),
Action(
@@ -857,6 +891,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/news/{news_slug}",
summary="View a news article by slug",
requires_auth=False,
params=(
path(
"news_slug",
@@ -904,6 +939,7 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/admin",
summary="View the admin overview",
requires_admin=True,
),
Action(
name="site_analytics",
@@ -946,6 +982,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/users",
summary="List users for administration",
params=(query("page", "Page number."),),
requires_admin=True,
),
Action(
name="admin_set_user_role",
@@ -956,6 +993,7 @@ ACTIONS: tuple[Action, ...] = (
path("uid", "User uid."),
body("role", "New role.", required=True),
),
requires_admin=True,
),
Action(
name="admin_set_user_password",
@@ -966,6 +1004,7 @@ ACTIONS: tuple[Action, ...] = (
path("uid", "User uid."),
body("password", "New password.", required=True),
),
requires_admin=True,
),
Action(
name="admin_toggle_user",
@@ -973,12 +1012,14 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/users/{uid}/toggle",
summary="Toggle a user's active state",
params=(path("uid", "User uid."),),
requires_admin=True,
),
Action(
name="admin_get_settings",
method="GET",
path="/admin/settings",
summary="View site settings",
requires_admin=True,
),
Action(
name="admin_save_settings",
@@ -1000,6 +1041,7 @@ ACTIONS: tuple[Action, ...] = (
body("maintenance_mode", "Whether maintenance mode is on."),
body("maintenance_message", "Maintenance message."),
),
requires_admin=True,
),
Action(
name="admin_list_news",
@@ -1007,6 +1049,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/news",
summary="List news for administration",
params=(query("page", "Page number."),),
requires_admin=True,
),
Action(
name="admin_toggle_news",
@@ -1014,6 +1057,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/news/{uid}/toggle",
summary="Toggle a news article",
params=(path("uid", "News uid."),),
requires_admin=True,
),
Action(
name="admin_publish_news",
@@ -1021,6 +1065,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/news/{uid}/publish",
summary="Publish a news article",
params=(path("uid", "News uid."),),
requires_admin=True,
),
Action(
name="admin_landing_news",
@@ -1028,6 +1073,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/news/{uid}/landing",
summary="Set a news article as landing content",
params=(path("uid", "News uid."),),
requires_admin=True,
),
Action(
name="admin_delete_news",
@@ -1035,18 +1081,21 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/news/{uid}/delete",
summary="Delete a news article",
params=(path("uid", "News uid."),),
requires_admin=True,
),
Action(
name="admin_list_services",
method="GET",
path="/admin/services",
summary="View managed services",
requires_admin=True,
),
Action(
name="admin_services_data",
method="GET",
path="/admin/services/data",
summary="Get live status, metrics, and log tail for every background service",
requires_admin=True,
),
Action(
name="admin_service_status",
@@ -1054,6 +1103,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/services/{name}/data",
summary="Get live status, metrics, and log tail for one background service",
params=(path("name", "Service name (e.g. news, bots, openai)."),),
requires_admin=True,
),
Action(
name="admin_start_service",
@@ -1061,6 +1111,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/services/{name}/start",
summary="Start a managed service",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_stop_service",
@@ -1068,6 +1119,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/services/{name}/stop",
summary="Stop a managed service",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_run_service",
@@ -1075,6 +1127,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/services/{name}/run",
summary="Run a managed service once",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_clear_service_logs",
@@ -1082,6 +1135,7 @@ ACTIONS: tuple[Action, ...] = (
path="/admin/services/{name}/clear-logs",
summary="Clear a managed service's logs",
params=(path("name", "Service name."),),
requires_admin=True,
),
Action(
name="admin_config_service",
@@ -1090,6 +1144,62 @@ ACTIONS: tuple[Action, ...] = (
summary="Update a managed service's configuration",
params=(path("name", "Service name."),),
freeform_body=True,
requires_admin=True,
),
Action(
name="admin_user_ai_usage",
method="GET",
path="/admin/users/{uid}/ai-usage",
summary="Get a user's AI gateway usage (admin only)",
description="Returns per-user AI usage data including token counts, cost, and request volume for the given lookback window.",
params=(
path("uid", "User uid."),
query("hours", "Lookback window in hours (default 24)."),
),
requires_admin=True,
),
Action(
name="admin_reset_user_ai_quota",
method="POST",
path="/admin/users/{uid}/reset-ai-quota",
summary="Reset a user's AI quota (admin only)",
description="Clears the AI quota ledger for a specific user, allowing them to use AI features again.",
params=(path("uid", "User uid."),),
requires_admin=True,
),
Action(
name="admin_media_purge",
method="POST",
path="/admin/media/{uid}/purge",
summary="Permanently remove soft-deleted media (admin only)",
description="Permanently deletes a soft-deleted attachment from the database and filesystem.",
params=(path("uid", "Attachment uid to purge."),),
requires_admin=True,
),
Action(
name="admin_reset_guest_ai_quota",
method="POST",
path="/admin/ai-quota/reset-guests",
summary="Reset all guest AI quotas (admin only)",
description="Clears the AI quota ledger for all guest sessions.",
requires_admin=True,
),
Action(
name="admin_reset_all_ai_quota",
method="POST",
path="/admin/ai-quota/reset-all",
summary="Reset ALL AI quotas including member quotas (admin only)",
description="Clears the AI quota ledger for every user and guest. Use with caution.",
requires_admin=True,
),
Action(
name="restore_media",
method="POST",
path="/media/{uid}/restore",
summary="Restore a soft-deleted media attachment (admin only)",
description="Restores a previously soft-deleted attachment, making it visible again.",
params=(path("uid", "Attachment uid to restore."),),
requires_admin=True,
),
)
@@ -32,6 +32,7 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {
"project_set_private",
"project_set_readonly",
"customize_set_css",
"customize_set_js",
@@ -39,6 +40,11 @@ CONFIRM_REQUIRED = {
"project_delete_file",
"delete_project",
"delete_media",
"regenerate_api_key",
"delete_post",
"delete_comment",
"delete_gist",
"delete_attachment",
}
DESTRUCTIVE_COMMAND = re.compile(
@@ -112,6 +118,13 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
"confirm=true."
)
if name == "project_set_private":
value = str(arguments.get("value", "")).strip()
return ToolInputError(
f"Setting a project {'private' if value == 'true' else 'public'} "
"hides it from (or exposes it to) other members. Ask the user to confirm this "
"change explicitly, then call again with confirm=true."
)
if name == "project_set_readonly":
return ToolInputError(
"Setting a project read-only makes every file immutable and blocks all further "
@@ -135,6 +148,36 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"project. Show the user the exact media, get explicit confirmation, then call again "
"with confirm=true."
)
if name == "regenerate_api_key":
return ToolInputError(
"Regenerating your API key immediately invalidates the current key, which may break "
"scripts or services that use it. Ask the user to confirm explicitly, then call again "
"with confirm=true."
)
if name == "delete_post":
slug = str(arguments.get("slug", arguments.get("post_slug", ""))).strip()
return ToolInputError(
f"Deleting the post '{slug}' is permanent and removes it from the site. "
"Ask the user to confirm explicitly, then call again with confirm=true."
)
if name == "delete_comment":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting comment '{uid}' is permanent and cannot be undone. "
"Ask the user to confirm explicitly, then call again with confirm=true."
)
if name == "delete_gist":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting gist '{uid}' is permanent. Show the user the exact gist, "
"get explicit confirmation, then call again with confirm=true."
)
if name == "delete_attachment":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting attachment '{uid}' is permanent and removes it from its parent. "
"Ask the user to confirm explicitly, then call again with confirm=true."
)
if (
name == "container_instance_action"
and str(arguments.get("action", "")).strip().lower() == "delete"
+3 -3
View File
@@ -261,14 +261,14 @@ class DeviiSession:
def _pick_target(self) -> Any:
best = None
best_rank = None
highest_rank = None
for ws in self._conns:
meta = self._conn_meta.get(ws)
if meta is None:
continue
rank = (meta["focused"], meta["visible"], meta["seq"])
if best_rank is None or rank > best_rank:
best_rank = rank
if highest_rank is None or rank > highest_rank:
highest_rank = rank
best = ws
return best
+5 -5
View File
@@ -106,13 +106,13 @@ class JobService(BaseService):
else:
self._finish_done(uid, task.result() or {}, duration_ms)
def _finish_done(self, uid: str, result_data: dict, duration_ms: int) -> None:
def _finish_done(self, uid: str, job_result: dict, duration_ms: int) -> None:
now = datetime.now(timezone.utc)
get_table("jobs").update(
{
"uid": uid,
"status": queue.DONE,
"result": json.dumps(result_data),
"result": json.dumps(job_result),
"error": "",
"completed_at": now.isoformat(),
"updated_at": now.isoformat(),
@@ -121,9 +121,9 @@ class JobService(BaseService):
"expires_at": (
now + timedelta(seconds=self.retention_seconds())
).isoformat(),
"bytes_in": int(result_data.get("bytes_in", 0)),
"bytes_out": int(result_data.get("bytes_out", 0)),
"item_count": int(result_data.get("item_count", 0)),
"bytes_in": int(job_result.get("bytes_in", 0)),
"bytes_out": int(job_result.get("bytes_out", 0)),
"item_count": int(job_result.get("item_count", 0)),
},
["uid"],
)
+17 -3
View File
@@ -37,9 +37,13 @@ class ZipService(JobService):
super().__init__(name="zip", interval_seconds=2)
async def process(self, job: dict) -> dict:
from devplacepy.services.audit import record as audit
source = job["payload"].get("source", {})
uid = job["uid"]
staging = STAGING_DIR / uid
project_uid = source.get("project_uid", "")
fail_meta = {"project_uid": project_uid}
try:
item_count = await asyncio.to_thread(self._materialize, source, staging)
tmp_dir = ZIPS_DIR / _shard(uid)
@@ -49,12 +53,22 @@ class ZipService(JobService):
final_name = self._final_name(job.get("preferred_name", ""), stats["crc32"])
final_path = tmp_dir / final_name
os.replace(tmp_zip, final_path)
except Exception:
audit.record_system(
"job.zip.failed",
actor_kind="user" if job.get("owner_kind") == "user" else (job.get("owner_kind") or "system"),
actor_uid=job.get("owner_id") if job.get("owner_kind") == "user" else None,
result="failure",
target_type="project",
target_uid=project_uid,
metadata=fail_meta,
summary=f"zip job for project {project_uid} failed",
links=[audit.project(project_uid), audit.job(uid)],
)
raise
finally:
await asyncio.to_thread(shutil.rmtree, staging, ignore_errors=True)
from devplacepy.services.audit import record as audit
project_uid = source.get("project_uid", "")
audit.record_system(
"job.zip.complete",
actor_kind="user" if job.get("owner_kind") == "user" else (job.get("owner_kind") or "system"),
+6 -8
View File
@@ -1,7 +1,6 @@
# retoor <retoor@molodetz.nl>
import json
import os
import shutil
import sys
import zipfile
@@ -20,23 +19,22 @@ def _build(source_dir: str, output_path: str) -> dict:
source_path = Path(source_dir)
output_path_obj = Path(output_path)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as archive:
for root, dirs, files in os.walk(source_dir):
for root, dirs, files in Path(source_dir).walk():
dirs.sort()
root_path = Path(root)
for name in dirs:
dir_count += 1
arcname = str(Path(root_path / name).relative_to(source_path)) + "/"
arcname = str((root / name).relative_to(source_path)) + "/"
info = zipfile.ZipInfo(arcname, date_time=FIXED_DATE)
info.external_attr = (0o040755 << 16) | 0x10
archive.writestr(info, b"")
for name in sorted(files):
full = str(root_path / name)
arcname = str(Path(full).relative_to(source_path))
bytes_in += Path(full).stat().st_size
full = root / name
arcname = str(full.relative_to(source_path))
bytes_in += full.stat().st_size
info = zipfile.ZipInfo(arcname, date_time=FIXED_DATE)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
with open(full, "rb") as source, archive.open(info, "w") as target:
with full.open("rb") as source, archive.open(info, "w") as target:
shutil.copyfileobj(source, target)
file_count += 1
+3 -3
View File
@@ -302,7 +302,7 @@
.pagination-btn:hover {
background: var(--accent);
color: #fff;
color: var(--white);
border-color: var(--accent);
text-decoration: none;
}
@@ -346,12 +346,12 @@
.pagination-page-active {
background: var(--accent);
color: #fff;
color: var(--white);
}
.pagination-page-active:hover {
background: var(--accent-hover);
color: #fff;
color: var(--white);
}
.pagination-ellipsis {
+1 -1
View File
@@ -99,7 +99,7 @@
font-size: 1rem;
font-weight: 700;
background: var(--accent);
color: #fff;
color: var(--white);
border-radius: var(--radius);
}
+1 -1
View File
@@ -757,7 +757,7 @@ img {
button.comment-form-submit {
padding: 0.375rem 0.5rem;
background: var(--accent);
color: #fff;
color: var(--white);
font-weight: 600;
font-size: 0.8125rem;
line-height: 1.4;
+6 -6
View File
@@ -190,8 +190,8 @@ devii-terminal .devii-winctl button:hover {
}
devii-terminal .devii-winctl button[data-win="close"]:hover {
background: #c23b3b;
color: #fff;
background: var(--danger);
color: var(--white);
}
/* Toolbar */
@@ -299,7 +299,7 @@ devii-terminal .devii-agent h4,
devii-terminal .devii-agent h5,
devii-terminal .devii-agent h6 {
margin: 0.4em 0 0.2em;
color: #fff;
color: var(--white);
line-height: 1.3;
}
devii-terminal .devii-agent p {
@@ -365,7 +365,7 @@ devii-terminal .md-copy:focus-visible {
}
devii-terminal .md-copy:hover {
border-color: var(--devii-accent);
color: #fff;
color: var(--white);
}
devii-terminal .md-copy.md-copied {
color: var(--devii-accent);
@@ -406,7 +406,7 @@ devii-terminal .devii-copy:focus-visible {
devii-terminal .devii-copy:hover {
opacity: 1;
border-color: var(--devii-accent);
color: #fff;
color: var(--white);
}
devii-terminal .devii-copy.md-copied {
opacity: 1;
@@ -453,7 +453,7 @@ devii-terminal .devii-agent .md-table td {
}
devii-terminal .devii-agent .md-table thead th {
background: var(--devii-bar-bg);
color: #fff;
color: var(--white);
font-weight: 600;
}
devii-terminal .devii-agent .md-table tbody tr:nth-child(even) {
+6 -6
View File
@@ -165,7 +165,7 @@
}
.method-badge {
color: #fff;
color: var(--white);
}
.method-get { background: var(--info); }
@@ -375,7 +375,7 @@ textarea.param-input {
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: var(--radius);
color: #fff;
color: var(--white);
}
.response-2xx { background: var(--success); }
@@ -431,7 +431,7 @@ textarea.param-input {
.format-option.active {
background: var(--accent);
color: #fff;
color: var(--white);
}
.format-fixed {
@@ -506,7 +506,7 @@ pre.code-pre {
padding: 0;
margin: 0 0 0.75rem;
overflow: hidden;
background: #282c34;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: "SF Mono", Monaco, "Cascadia Code", "JetBrains Mono", monospace;
@@ -566,7 +566,7 @@ pre.code-has-copy > .code-copy-btn {
}
pre.code-has-copy > .code-copy-btn:hover {
color: #fff;
color: var(--white);
border-color: var(--accent, #6366f1);
}
@@ -638,7 +638,7 @@ pre.code-has-copy > .code-copy-btn:hover {
.docs-search-snippet mark {
background: var(--accent, #6366f1);
color: #fff;
color: var(--white);
padding: 0 0.125rem;
border-radius: 3px;
}
+2 -2
View File
@@ -36,7 +36,7 @@
.feed-nav-btn.active {
background: var(--accent);
color: #fff;
color: var(--white);
}
.feed-nav-actions {
@@ -331,7 +331,7 @@
}
.feed-fab:hover {
background: #c62828;
background: var(--danger);
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(229, 57, 53, 0.5);
}
+2 -2
View File
@@ -153,8 +153,8 @@
}
.fw-host .fw-winctl button[data-win="close"]:hover {
background: #c23b3b;
color: #fff;
background: var(--danger);
color: var(--white);
}
.fw-host .fw-body {
+1 -1
View File
@@ -227,7 +227,7 @@
margin: 0;
padding: 1rem;
overflow-x: auto;
background: #1a1a2e;
background: var(--bg-card);
font-size: 0.8125rem;
line-height: 1.5;
}
+1 -1
View File
@@ -54,7 +54,7 @@
font-size: 1.125rem;
border-radius: var(--radius-lg);
background: var(--accent);
color: #fff;
color: var(--white);
font-weight: 700;
}
+1 -1
View File
@@ -150,7 +150,7 @@
border-radius: var(--radius);
margin: 0.5rem 0;
border: 1px solid var(--border);
background: #000;
background: var(--bg-primary);
}
.rendered-content a[href^="http"]::after {
+2 -2
View File
@@ -148,7 +148,7 @@
.message-bubble.mine {
align-self: flex-end;
background: var(--accent);
color: #fff;
color: var(--white);
border-bottom-right-radius: 4px;
}
@@ -203,7 +203,7 @@
height: 36px;
padding: 0;
background: var(--accent);
color: #fff;
color: var(--white);
border-radius: 50%;
display: flex;
align-items: center;
+1 -1
View File
@@ -244,7 +244,7 @@
font-size: 0.875rem;
font-weight: 600;
background: var(--accent);
color: #fff;
color: var(--white);
border-radius: var(--radius);
}
+1 -1
View File
@@ -182,7 +182,7 @@ a.profile-stat-value:hover {
.profile-tab.active {
background: var(--accent);
color: #fff;
color: var(--white);
}
.profile-posts {
+2 -2
View File
@@ -101,7 +101,7 @@
.pf-node-row.active {
background: var(--accent);
color: #fff;
color: var(--white);
}
.pf-node-row.pf-selected {
@@ -111,7 +111,7 @@
.pf-node-row.pf-selected.active {
background: var(--accent);
color: #fff;
color: var(--white);
}
.pf-node-row.pf-dragging {
+1 -1
View File
@@ -24,7 +24,7 @@
.projects-tab.active {
background: var(--accent);
color: #fff;
color: var(--white);
}
.projects-header {
+3 -3
View File
@@ -49,17 +49,17 @@
.service-status.running {
background: var(--topic-devlog, #10b981);
color: #fff;
color: var(--white);
}
.service-status.stopped {
background: var(--topic-discussion, #ef4444);
color: #fff;
color: var(--white);
}
.service-status.stalled {
background: var(--topic-question, #f59e0b);
color: #fff;
color: var(--white);
}
.service-controls {
+3 -3
View File
@@ -2,7 +2,7 @@
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { formatDate } from "./DateFormat.js";
import { DateFormat } from "./DateFormat.js";
class AiUsageMonitor {
constructor() {
@@ -123,7 +123,7 @@ class AiUsageMonitor {
render(data) {
this.root.textContent = "";
if (this.generated && data.generated_at) {
this.generated.textContent = `Window: ${data.window_hours}h - updated ${formatDate(data.generated_at, true)}`;
this.generated.textContent = `Window: ${data.window_hours}h - updated ${DateFormat.format(data.generated_at, true)}`;
}
if (!data.requests) {
this.root.appendChild(this.el("p", "admin-empty", "No AI gateway traffic recorded in this window yet."));
@@ -237,7 +237,7 @@ class AiUsageMonitor {
const hourlyRows = (data.hourly || []).map((h) => {
const [day, hour] = String(h.hour).split("T");
return [
`${formatDate(day)} ${(hour || "00").padStart(2, "0")}:00`,
`${DateFormat.format(day)} ${(hour || "00").padStart(2, "0")}:00`,
this.integer(h.requests),
this.integer(h.success),
this.integer(h.failed),
@@ -1,11 +1,11 @@
// retoor <retoor@molodetz.nl>
import { userScope } from "./userScope.js";
import { UserScope } from "./userScope.js";
const SESSION_NAME = "devplace";
function persistKey() {
return `ct-persistent:${userScope()}`;
return `ct-persistent:${UserScope.get()}`;
}
export class ContainerTerminalManager {
+18 -16
View File
@@ -1,20 +1,22 @@
// retoor <retoor@molodetz.nl>
export function formatDate(value, includeTime = false) {
if (!value) return "-";
let input = value;
if (typeof input === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input)) {
input = `${input}T00:00:00`;
export class DateFormat {
static format(value, includeTime = false) {
if (!value) return "-";
let input = value;
if (typeof input === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input)) {
input = `${input}T00:00:00`;
}
const date = new Date(input);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 19);
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const yyyy = date.getFullYear();
if (includeTime) {
const hh = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
return `${dd}/${mm}/${yyyy}`;
}
const date = new Date(input);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 19);
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const yyyy = date.getFullYear();
if (includeTime) {
const hh = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
return `${dd}/${mm}/${yyyy}`;
}
+2 -1
View File
@@ -1,5 +1,6 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import "./devii/devii-avatar.js";
import "./devii/devii-terminal.js";
@@ -13,7 +14,7 @@ export class DeviiTerminal {
async init() {
try {
await fetch("/devii/session", { credentials: "same-origin" });
await Http.getJson("/devii/session");
} catch (error) {
return;
}
+2 -2
View File
@@ -1,6 +1,6 @@
// retoor <retoor@molodetz.nl>
import { LANGUAGE_MODES } from "./codemirrorModes.js";
import { CodeMirrorModes } from "./codemirrorModes.js";
class GistEditor {
constructor(textareaId, langSelectId) {
@@ -43,7 +43,7 @@ class GistEditor {
if (langSelect) {
langSelect.addEventListener("change", () => {
const mode = langSelect.value;
this.editor.setOption("mode", LANGUAGE_MODES[mode] || mode);
this.editor.setOption("mode", CodeMirrorModes.LANGUAGE_MODES[mode] || mode);
});
}
+1 -1
View File
@@ -7,7 +7,7 @@ export class Http {
}
static async getJson(url) {
const response = await fetch(url);
const response = await fetch(url, { headers: { "Accept": "application/json" } });
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
+4 -7
View File
@@ -2,7 +2,7 @@
import { Http } from "./Http.js";
import { Toast } from "./Toast.js";
import { modeForFilename } from "./codemirrorModes.js";
import { CodeMirrorModes } from "./codemirrorModes.js";
export class ProjectFiles {
constructor(root) {
@@ -369,9 +369,7 @@ export class ProjectFiles {
async openFile(path) {
try {
const res = await fetch(`${this.base}/raw?path=${encodeURIComponent(path)}`, { headers: { "Accept": "application/json" } });
const data = await res.json();
if (!res.ok) throw new Error(data.error ? data.error.message : "Could not open file");
const data = await Http.getJson(`${this.base}/raw?path=${encodeURIComponent(path)}`);
this.currentPath = path;
this.currentLabel.textContent = path;
this.currentLabel.hidden = false;
@@ -390,7 +388,7 @@ export class ProjectFiles {
this.preview.hidden = true;
this.editorWrap.hidden = false;
if (this.editor) {
this.editor.setOption("mode", modeForFilename(path));
this.editor.setOption("mode", CodeMirrorModes.modeForFilename(path));
this.editor.setValue(content);
setTimeout(() => this.editor.refresh(), 50);
}
@@ -437,8 +435,7 @@ export class ProjectFiles {
}
async refresh(openPath) {
const res = await fetch(this.base, { headers: { "Accept": "application/json" } });
const data = await res.json();
const data = await Http.getJson(this.base);
this.files = data.files || [];
this.renderTree();
if (openPath) this.openFile(openPath);
+4 -4
View File
@@ -3,7 +3,7 @@
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { Toast } from "./Toast.js";
import { formatDate } from "./DateFormat.js";
import { DateFormat } from "./DateFormat.js";
class ServiceMonitor {
constructor() {
@@ -105,9 +105,9 @@ class ServiceMonitor {
statusEl.className = "service-status " + svc.status;
}
this.setMeta(root, "uptime", svc.uptime || "-");
this.setMeta(root, "last_run", svc.last_run ? formatDate(svc.last_run, true) : "-");
this.setMeta(root, "next_run", svc.next_run ? formatDate(svc.next_run, true) : "-");
this.setMeta(root, "heartbeat", svc.heartbeat ? formatDate(svc.heartbeat, true) : "-");
this.setMeta(root, "last_run", svc.last_run ? DateFormat.format(svc.last_run, true) : "-");
this.setMeta(root, "next_run", svc.next_run ? DateFormat.format(svc.next_run, true) : "-");
this.setMeta(root, "heartbeat", svc.heartbeat ? DateFormat.format(svc.heartbeat, true) : "-");
this.setMeta(root, "interval", "every " + svc.interval_seconds + "s");
const logPre = root.querySelector(".log-output");
+2 -2
View File
@@ -1,7 +1,7 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { formatDate } from "./DateFormat.js";
import { DateFormat } from "./DateFormat.js";
class UserAiUsage {
constructor() {
@@ -83,7 +83,7 @@ class UserAiUsage {
}
when(value) {
return formatDate(value, true);
return DateFormat.format(value, true);
}
escape(text) {
+55 -53
View File
@@ -1,59 +1,61 @@
// retoor <retoor@molodetz.nl>
export const LANGUAGE_MODES = {
"c": "text/x-csrc",
"cpp": "text/x-c++src",
"java": "text/x-java",
"bash": "text/x-sh",
"ruby": "text/x-ruby",
"php": "text/x-php",
"perl": "text/x-perl",
"haskell": "text/x-haskell",
"lua": "text/x-lua",
"elixir": "text/x-elixir",
"dart": "text/x-dart",
"r": "text/x-rsrc",
"scala": "text/x-scala",
};
export class CodeMirrorModes {
static LANGUAGE_MODES = {
"c": "text/x-csrc",
"cpp": "text/x-c++src",
"java": "text/x-java",
"bash": "text/x-sh",
"ruby": "text/x-ruby",
"php": "text/x-php",
"perl": "text/x-perl",
"haskell": "text/x-haskell",
"lua": "text/x-lua",
"elixir": "text/x-elixir",
"dart": "text/x-dart",
"r": "text/x-rsrc",
"scala": "text/x-scala",
};
export const EXTENSION_MODES = {
"py": "python",
"js": "javascript", "mjs": "javascript", "cjs": "javascript", "jsx": "javascript",
"ts": "text/typescript", "tsx": "text/typescript",
"json": "application/json", "jsonc": "application/json",
"c": "text/x-csrc", "h": "text/x-csrc",
"cpp": "text/x-c++src", "cc": "text/x-c++src", "hpp": "text/x-c++src", "hh": "text/x-c++src",
"cs": "text/x-csharp",
"java": "text/x-java",
"go": "go",
"rb": "text/x-ruby",
"php": "text/x-php",
"pl": "text/x-perl", "pm": "text/x-perl",
"lua": "text/x-lua",
"r": "text/x-rsrc",
"dart": "text/x-dart",
"swift": "swift",
"hs": "text/x-haskell",
"sql": "sql",
"sh": "text/x-sh", "bash": "text/x-sh", "zsh": "text/x-sh",
"yaml": "yaml", "yml": "yaml",
"md": "markdown", "markdown": "markdown",
"html": "htmlmixed", "htm": "htmlmixed",
"xml": "xml", "svg": "xml",
"css": "css", "scss": "css", "sass": "css", "less": "css",
};
static EXTENSION_MODES = {
"py": "python",
"js": "javascript", "mjs": "javascript", "cjs": "javascript", "jsx": "javascript",
"ts": "text/typescript", "tsx": "text/typescript",
"json": "application/json", "jsonc": "application/json",
"c": "text/x-csrc", "h": "text/x-csrc",
"cpp": "text/x-c++src", "cc": "text/x-c++src", "hpp": "text/x-c++src", "hh": "text/x-c++src",
"cs": "text/x-csharp",
"java": "text/x-java",
"go": "go",
"rb": "text/x-ruby",
"php": "text/x-php",
"pl": "text/x-perl", "pm": "text/x-perl",
"lua": "text/x-lua",
"r": "text/x-rsrc",
"dart": "text/x-dart",
"swift": "swift",
"hs": "text/x-haskell",
"sql": "sql",
"sh": "text/x-sh", "bash": "text/x-sh", "zsh": "text/x-sh",
"yaml": "yaml", "yml": "yaml",
"md": "markdown", "markdown": "markdown",
"html": "htmlmixed", "htm": "htmlmixed",
"xml": "xml", "svg": "xml",
"css": "css", "scss": "css", "sass": "css", "less": "css",
};
const FILENAME_MODES = {
"dockerfile": "text/x-sh",
"makefile": "text/x-sh",
".gitignore": "text/plain",
".env": "text/plain",
};
static FILENAME_MODES = {
"dockerfile": "text/x-sh",
"makefile": "text/x-sh",
".gitignore": "text/plain",
".env": "text/plain",
};
export function modeForFilename(name) {
const lower = (name || "").toLowerCase();
if (FILENAME_MODES[lower]) return FILENAME_MODES[lower];
const dot = lower.lastIndexOf(".");
const ext = dot >= 0 ? lower.slice(dot + 1) : "";
return EXTENSION_MODES[ext] || "plaintext";
static modeForFilename(name) {
const lower = (name || "").toLowerCase();
if (this.FILENAME_MODES[lower]) return this.FILENAME_MODES[lower];
const dot = lower.lastIndexOf(".");
const ext = dot >= 0 ? lower.slice(dot + 1) : "";
return this.EXTENSION_MODES[ext] || "plaintext";
}
}
@@ -2,7 +2,7 @@
import FloatingWindow from "./FloatingWindow.js";
import { Http } from "../Http.js";
import { userScope } from "../userScope.js";
import { UserScope } from "../userScope.js";
const XTERM_JS = "/static/vendor/xterm/xterm.js";
const XTERM_CSS = "/static/vendor/xterm/xterm.css";
@@ -16,7 +16,7 @@ let xtermLoading = null;
let cascade = 0;
function fontKey() {
return `ct-fontsize:${userScope()}`;
return `ct-fontsize:${UserScope.get()}`;
}
function readFontSize() {
+4 -4
View File
@@ -1,10 +1,11 @@
// retoor <retoor@molodetz.nl>
import FloatingWindow from "../components/FloatingWindow.js";
import { Http } from "../Http.js";
import Markdown from "./markdown.js";
import DeviiSocket from "./DeviiSocket.js";
import DeviiClient from "./DeviiClient.js";
import { userScope } from "../userScope.js";
import { UserScope } from "../userScope.js";
const CSS_ID = "devii-terminal-css";
const STORAGE_KEY = "devii-terminal-state";
@@ -353,7 +354,7 @@ export default class DeviiTerminalElement extends FloatingWindow {
}
_fontKey() {
return `${FONT_KEY}:${userScope()}`;
return `${FONT_KEY}:${UserScope.get()}`;
}
_readFont() {
@@ -561,8 +562,7 @@ export default class DeviiTerminalElement extends FloatingWindow {
async _fetchIdentity() {
try {
const response = await fetch("/devii/session", { credentials: "same-origin" });
const data = await response.json();
const data = await Http.getJson("/devii/session");
if (data.baseUrl) {
this.markdown.baseUrl = data.baseUrl;
}
+10 -8
View File
@@ -1,11 +1,13 @@
// retoor <retoor@molodetz.nl>
export function userScope() {
const docs = window.DEVPLACE_DOCS || {};
let username = docs.username || "";
if (!username) {
const el = document.querySelector(".topnav-user-name");
if (el) username = el.textContent.trim();
}
return username ? `user:${username}` : "guest";
export class UserScope {
static get() {
const docs = window.DEVPLACE_DOCS || {};
let username = docs.username || "";
if (!username) {
const el = document.querySelector(".topnav-user-name");
if (el) username = el.textContent.trim();
}
return username ? `user:${username}` : "guest";
}
}
+1 -3
View File
@@ -14,9 +14,7 @@
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-header">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}">
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
</a>
{% set _user = item.author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% set _user = item.author %}{% set _class = "comment-author" %}{% include "_user_link.html" %}
<span class="comment-time">{{ item.time_ago }}</span>
</div>
+1 -3
View File
@@ -2,9 +2,7 @@
<form class="comment-form" method="POST" action="/comments/create">
<input type="hidden" name="target_uid" value="{{ _comment_target_uid }}">
<input type="hidden" name="target_type" value="{{ _comment_target_type }}">
<a href="/profile/{{ user['username'] }}">
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
</a>
{% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<textarea name="content" placeholder="Your opinion goes here..." required maxlength="1000" class="emoji-picker-target" data-mention></textarea>
<div class="comment-form-actions">
<dp-upload multiple
@@ -69,13 +69,28 @@ make agents-all CHECK=1 # run every agent in order, report only
This is the command to run before a release, or in a continuous-integration job
(in `CHECK=1` mode it returns a non-zero exit code if anything is wrong).
## What you see while it runs
The output is meant to be read live, so you always know what is happening:
- **A start banner** for every agent: its name, a memorable codename (like
`brave-otter`), what it is about to do, and where its report will be written.
- **A timestamp and elapsed time** on every line, so you can see how long things take.
- **A running cost** after each AI step (per call and total), with money icons.
- **A diff** of every file change as it happens, so nothing is edited silently.
- **Live command output** streamed line by line while a command runs.
## Reading the reports
Every run writes its findings to `agents/reports/`:
Every run writes its findings to `agents/reports/`, named
`<agent>-<codename>-<date>` so a run is easy to refer to:
- `<agent>-<date>.json` - the machine-readable findings.
- `<agent>-<date>.md` - a readable summary grouped by file.
- `fleet-<date>.json` - the combined report when you run the whole fleet.
- `<agent>-<codename>-<date>.json` - the machine-readable findings.
- `<agent>-<codename>-<date>.md` - a readable summary grouped by file.
- `fleet-<codename>-<date>.json` - the combined report when you run the whole fleet.
A report marked `incomplete` means the run ran out of its step budget before
finishing; its findings are partial and it is worth running again.
## Validating the code yourself
+1 -3
View File
@@ -32,9 +32,7 @@
{% if other_user %}
<div class="messages-main-header">
<button type="button" class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">&#x2190;</button>
<a href="/profile/{{ other_user['username'] }}">
<img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy">
</a>
{% set _user = other_user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
</div>
+4 -4
View File
@@ -24,9 +24,9 @@
{% set _href = "/notifications/open/" ~ item.notification['uid'] %}
{% set _label = item.notification['message'] %}
{% include "_card_link.html" %}
<a href="/profile/{{ actor_username }}" style="flex-shrink:0">
<img src="{{ avatar_url('multiavatar', actor_username, 32) }}" class="avatar-img avatar-sm" alt="{{ actor_username }}" loading="lazy">
</a>
<span style="flex-shrink:0">
{% set _user = item.actor %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
</span>
<div class="notification-body">
<div class="notification-text">{{ item.notification['message'] }}</div>
<div class="notification-time">{{ item.time_ago }}</div>
@@ -48,4 +48,4 @@
</div>
{% endif %}
</div>
{% endblock %}
{% endblock %}
+2 -4
View File
@@ -10,9 +10,7 @@
<article class="post-detail">
<div class="post-detail-header">
<a href="/profile/{{ author['username'] if author else '#' }}">
<img src="{{ avatar_url('multiavatar', author['username'] if author else '?', 40) }}" class="avatar-img avatar-md" alt="{{ author['username'] if author else '?' }}" loading="lazy">
</a>
{% set _user = author %}{% set _size = 40 %}{% set _size_class = "md" %}{% include "_avatar_link.html" %}
<div>
{% set _user = author %}{% set _class = "post-detail-author" %}{% include "_user_link.html" %}
{% if is_admin(user) and author and author.get('role') %}
@@ -100,7 +98,7 @@
{% for item in related_posts %}
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="related-link">
<strong class="related-title">{{ item.post.get('title') or item.post['content'][:60] }}</strong>
<span class="related-meta">{{ item.author['username'] }} · {{ item.time_ago }}</span>
<span class="related-meta">{{ item.author['username'] }} &middot; {{ item.time_ago }}</span>
</a>
{% endfor %}
</div>
+2 -2
View File
@@ -8,7 +8,7 @@ from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.database import get_int_setting, get_setting, get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
from devplacepy.utils import badge_info, is_admin
from devplacepy.utils import get_badge, is_admin
from devplacepy.attachments import format_file_size, file_icon_emoji
from devplacepy.content import is_owner as _owns
from devplacepy.customization import custom_css_tag, custom_js_tag, page_type_for
@@ -78,7 +78,7 @@ templates.env.globals["get_unread_messages"] = jinja_unread_messages
templates.env.globals["get_user_projects"] = jinja_user_projects
templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["format_date"] = _format_date
templates.env.globals["badge_info"] = badge_info
templates.env.globals["badge_info"] = get_badge
templates.env.globals["TOPICS"] = TOPICS
templates.env.globals["REACTION_EMOJI"] = REACTION_EMOJI
+1 -1
View File
@@ -433,7 +433,7 @@ BADGE_CATALOG = {
}
def badge_info(badge_name: str) -> dict:
def get_badge(badge_name: str) -> dict:
return BADGE_CATALOG.get(badge_name, {"icon": "", "description": badge_name})