forked from retoor/devplacepy
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:
@@ -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
@@ -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 []
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--white);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
font-size: 1.125rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--white);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--white);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ a.profile-stat-value:hover {
|
||||
|
||||
.profile-tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.profile-posts {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
.projects-tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.projects-header {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">←</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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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'] }} · {{ item.time_ago }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user