agnes-the-ai-analyst/app/web/templates/admin_store_submissions.html
Vojtech 929520f5e1
Flea-market edit feature with version history (schema v37) (#239)
* feat(store): flea-market entity edit feature with version history (schema v38)

Owner + admin can now edit a store entity from a real Edit page at
/marketplace/flea/{id}/edit, replacing the prior "coming soon"
placeholder. Editable: display name, description, category, video
URL, cover photo, and an optional new bundle. Type is locked (400
type_locked). Display-name change renames the on-disk slug for both
live plugin/ and version dirs (reuses rename-on-archive helper).

Schema v38 (originally drafted as v37; renumbered after rebase onto
main where v37 was taken by the curated marketplace enrichment).

Versioning model:
* Each bundle update bakes into ${DATA_DIR}/store/<id>/versions/v<N+1>/plugin/
  and runs the standard guardrails pipeline.
* DEFERRED PROMOTION: live plugin/ + entity.version_no stay at the
  prior approved version through the LLM review window so existing
  installers keep receiving the previously approved bundle. Live swap
  + version_no/version/file_size bump happen only on LLM approval.
  Blocked verdicts leave the prior version serving forever.
* store_entities gains version_no INTEGER + version_history JSON.
  Each version_history entry carries hash, sha256, size, submission_id,
  created_at, created_by.
* Existing entities backfill to v1 with a single-entry history seeded
  from the row's current `version` hash. Initial create also seeds
  versions/v1/plugin/ so future restore can copy v1 bytes forward.

Concurrency:
* Block-while-pending: an in-flight LLM review blocks any further edit
  with 409 prior_version_pending. Owner waits 5-30s; Edit button on
  detail page renders disabled in the same window via the new
  edit_in_flight flag (decoupled from quarantine_sub since the
  deferred-promotion flow keeps visibility='approved').

Rollback:
* New endpoint POST /api/store/entities/{id}/versions/{n}/restore
  (owner + admin). Copies vN bundle forward as v<max+1> and re-runs
  guardrails (rules tighten over time; pre-approved bundles re-validate).
  Forward-only history. Same deferred-promotion semantics — live stays
  at prior version until LLM approves the restored copy.

UI:
* New /marketplace/flea/{id}/edit page (owner + admin gated).
* Versions card on plugin + item detail templates (owner/admin only)
  via shared _flea_versions.html partial.
* Admin queue gains v# column with current badge + separate Hash
  column. Submission detail surfaces Version + Bundle hash rows.
* Activity timeline split into per-submission + entity-wide cards;
  entity-wide rows render vN chips when audit row params reference
  a specific version.
* Section headers (Manifest / Static / Quality / LLM review) tag
  with vN chip via shared macro.
* Reviewed-by-model field surfaces explanatory text per status.
* Banner upload-failure now redirects to detail page on
  submission_blocked instead of staying stuck.

Tests: 24 in tests/test_store_entity_versions.py covering metadata-
only edit, bundle-edit version bump, type lock, block-while-pending,
name change disk rename, restore flow + 404/400/403 paths, edit page
404 for non-owner, versions card visibility gating, admin queue v#
column, admin detail Version/Hash rows, deferred-promotion installer
contract (pending review doesn't break installer / blocked verdict
keeps prior / approved promotes), admin can edit/restore non-owned,
restore deferred promotion, audit log per-version params. 214 tests
green across guardrails + edit + admin + repo + schema suites.

* docs(store): refresh update_entity docstring to match deferred-promotion + submission-status gate

Bring the docstring in sync with the actual fixes from the prior
commit. The pre-fix wording said the gate read
visibility_status='pending' AND submission status — under deferred
promotion that would never fire for v2+ edits. Now describes:

- Block-while-pending gates on submission.status DIRECTLY,
  independent of visibility (so v2+ deferred-promotion edits don't
  slip through).
- Display-name + bundle change defers the live rename to promotion;
  metadata-only renames stay immediate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:14:33 +04:00

348 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Store submissions — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
/* Width + padding come from .page-shell (style-custom.css) — same
1280px container as /dashboard, /marketplace, /admin/* peers. */
.subs-title { margin: 0 0 8px 0; font-size: 22px; font-weight: 600; }
.subs-help { color: var(--text-secondary, #6b7280); font-size: 13px; margin-bottom: 20px; }
.subs-help code { background: var(--border-light, #f3f4f6); padding: 1px 6px; border-radius: 4px; font-size: 12px; }
.subs-form {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr)) auto auto;
gap: 8px;
margin-bottom: 12px;
align-items: end;
}
.subs-form label { font-size: 11px; color: var(--text-secondary, #6b7280); text-transform: uppercase; letter-spacing: 0.4px; display: block; margin-bottom: 4px; }
.subs-form input, .subs-form select {
width: 100%; padding: 6px 10px; font-size: 13px;
border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
background: var(--surface, #fff); color: var(--text, #111827);
}
.subs-form .submit-btn {
padding: 7px 14px; border-radius: 6px;
background: var(--text, #111827); color: var(--surface, #fff);
border: 1px solid var(--text, #111827); font-size: 13px; cursor: pointer;
}
.subs-form .reset-btn {
padding: 7px 14px; border-radius: 6px;
background: var(--surface, #fff); color: var(--text, #111827);
border: 1px solid var(--border, #e5e7eb); font-size: 13px; text-decoration: none;
}
.subs-status-chips { margin-bottom: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.subs-status-chips a {
text-decoration: none; padding: 6px 12px; border-radius: 6px;
border: 1px solid var(--border, #e5e7eb); color: var(--text, #111827);
font-size: 12px; font-weight: 500;
}
.subs-status-chips a.active {
background: var(--text, #111827); color: var(--surface, #fff);
border-color: var(--text, #111827);
}
.subs-filter-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; margin-bottom: 12px;
background: #dbeafe; color: #1e40af; border-radius: 999px;
font-size: 12px; font-weight: 500;
}
.subs-filter-chip a { color: inherit; text-decoration: none; }
.subs-table-wrap {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
overflow-x: auto;
}
.subs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.subs-table thead th {
text-align: left; padding: 12px 16px;
background: var(--border-light, #f9fafb);
border-bottom: 1px solid var(--border, #e5e7eb);
font-weight: 600; color: var(--text-secondary, #6b7280);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
white-space: nowrap;
}
.subs-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid var(--border-light, #f3f4f6);
vertical-align: top;
}
.subs-table tbody tr { cursor: pointer; }
.subs-table tbody tr:last-child td { border-bottom: none; }
.subs-table tbody tr:hover { background: var(--border-light, #fafafa); }
.subs-table .ts { white-space: nowrap; color: var(--text-secondary, #6b7280); font-variant-numeric: tabular-nums; }
.subs-table .submitter a { color: var(--text-secondary, #4b5563); text-decoration: none; font-size: 12px; }
.subs-table .submitter a:hover { text-decoration: underline; color: var(--text, #111827); }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
}
.badge.pending_inline,
.badge.pending_llm {
background: #fef3c7; color: #92400e;
/* Subtle pulse so admins scanning the queue see in-flight reviews. */
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.badge.approved { background: #d1fae5; color: #065f46; }
.badge.overridden { background: #dbeafe; color: #1e40af; }
.badge.blocked_inline,
.badge.blocked_llm,
.badge.review_error { background: #fee2e2; color: #991b1b; }
.badge.archived { background: #e5e7eb; color: #374151; }
.badge.deleted { background: #4b5563; color: #f3f4f6; }
.findings {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;
color: var(--text-secondary, #6b7280); max-width: 540px;
word-break: break-word; white-space: pre-wrap;
}
.subs-paging {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; font-size: 12px; color: var(--text-secondary, #6b7280);
border-top: 1px solid var(--border-light, #f3f4f6);
}
.subs-paging a, .subs-paging .disabled {
padding: 4px 10px; border-radius: 6px; text-decoration: none;
color: var(--text, #111827); border: 1px solid var(--border, #e5e7eb);
margin: 0 2px;
}
.subs-paging .disabled { opacity: 0.4; pointer-events: none; }
.subs-paging select {
padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff); font-size: 12px;
}
.empty {
padding: 40px 16px; text-align: center;
color: var(--text-secondary, #6b7280); font-size: 13px;
}
</style>
{% set base_qs %}
{%- if status_filter %}status={{ status_filter }}&{% endif -%}
{%- if submitter_filter %}submitter={{ submitter_filter }}&{% endif -%}
{%- if type_filter %}type={{ type_filter }}&{% endif -%}
{%- if name_filter %}name={{ name_filter }}&{% endif -%}
{%- if version_filter %}version={{ version_filter }}&{% endif -%}
{%- if sort_filter %}sort={{ sort_filter }}&{% endif -%}
{%- if order_filter %}order={{ order_filter }}&{% endif -%}
{%- endset %}
{% set base_qs_no_sort %}
{%- if status_filter %}status={{ status_filter }}&{% endif -%}
{%- if submitter_filter %}submitter={{ submitter_filter }}&{% endif -%}
{%- if type_filter %}type={{ type_filter }}&{% endif -%}
{%- if name_filter %}name={{ name_filter }}&{% endif -%}
{%- if version_filter %}version={{ version_filter }}&{% endif -%}
{%- endset %}
{# Sort-link helper: clicking a sortable header toggles asc/desc on
that column while preserving every active filter. Default order on
first click of any column except created_at is ascending; clicking
the active column flips it. #}
{% macro sort_link(col, label) -%}
{% set is_active = (sort_filter == col) %}
{% set new_order = "desc" if (is_active and order_filter == "asc") else ("asc" if is_active else ("desc" if col == "created_at" else "asc")) %}
<a href="?{{ base_qs_no_sort }}sort={{ col }}&order={{ new_order }}"
style="color:inherit; text-decoration:none;">
{{ label }}{% if is_active %}{{ " ↓" if order_filter == "desc" else " ↑" }}{% endif %}
</a>
{%- endmacro %}
{# Format bytes to KB for display. Returns "—" when None. #}
{% macro fmt_size(b) -%}
{%- if b is none -%}—{%- elif b < 1024 -%}{{ b }} B{%- elif b < 1048576 -%}{{ "%.1f"|format(b / 1024) }} KB{%- else -%}{{ "%.1f"|format(b / 1048576) }} MB{%- endif -%}
{%- endmacro %}
<div class="subs-page page-shell">
<h1 class="subs-title">Store submissions</h1>
<p class="subs-help">
Every upload to <code>/api/store/entities</code> writes one row here. Inline
checks (manifest, static security, quality+templating) run synchronously;
the LLM security review runs in the background and flips
<code>pending_llm → approved</code> or <code>blocked_llm</code>. Click a
row to see the full check breakdown. Override a blocked submission to
force-publish (audit trail captured), retry a <code>review_error</code>,
or delete spam outright. See <code>docs/STORE_GUARDRAILS.md</code>.
</p>
<form class="subs-form" method="get" action="/admin/store/submissions">
{# Preserve status filter when submitting other filters. #}
{% if status_filter %}<input type="hidden" name="status" value="{{ status_filter }}">{% endif %}
{% if submitter_filter %}<input type="hidden" name="submitter" value="{{ submitter_filter }}">{% endif %}
<div>
<label for="f-type">Type</label>
<select id="f-type" name="type">
<option value="" {% if not type_filter %}selected{% endif %}>All</option>
<option value="skill" {% if type_filter == 'skill' %}selected{% endif %}>Skill</option>
<option value="agent" {% if type_filter == 'agent' %}selected{% endif %}>Agent</option>
<option value="plugin" {% if type_filter == 'plugin' %}selected{% endif %}>Plugin</option>
</select>
</div>
<div>
<label for="f-name">Name contains</label>
<input id="f-name" name="name" type="text" value="{{ name_filter }}" placeholder="e.g. summarizer">
</div>
<div>
<label for="f-version">Version contains</label>
<input id="f-version" name="version" type="text" value="{{ version_filter }}" placeholder="e.g. 1.0">
</div>
<div>
<label for="f-limit">Per page</label>
<select id="f-limit" name="limit">
{% for n in [25, 50, 100, 200] %}
<option value="{{ n }}" {% if limit == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<button class="submit-btn" type="submit">Apply</button>
<a class="reset-btn" href="/admin/store/submissions">Reset</a>
</form>
{% if submitter_filter %}
<div class="subs-filter-chip">
Submitter: <strong>{{ submitter_email or submitter_filter }}</strong>
<a href="/admin/store/submissions{% if status_filter or type_filter or name_filter or version_filter %}?{% if status_filter %}status={{ status_filter }}&{% endif %}{% if type_filter %}type={{ type_filter }}&{% endif %}{% if name_filter %}name={{ name_filter }}&{% endif %}{% if version_filter %}version={{ version_filter }}{% endif %}{% endif %}">✕ clear</a>
</div>
{% endif %}
<div class="subs-status-chips">
{% set _common %}{% if submitter_filter %}submitter={{ submitter_filter }}&{% endif %}{% if type_filter %}type={{ type_filter }}&{% endif %}{% if name_filter %}name={{ name_filter }}&{% endif %}{% if version_filter %}version={{ version_filter }}&{% endif %}{% endset %}
{# "All" excludes lifecycle-end states (archived, deleted) by default
— admin queue stays focused on actionable rows. The Archived /
Deleted chips opt back in. #}
<a href="/admin/store/submissions{% if _common %}?{{ _common[:-1] }}{% endif %}" class="{{ 'active' if not status_filter else '' }}">All</a>
{% set _pending = "pending_llm,pending_inline" %}
<a href="/admin/store/submissions?{{ _common }}status={{ _pending }}" class="{{ 'active' if status_filter == _pending else '' }}">Pending</a>
{% set _needs = "blocked_inline,blocked_llm,review_error" %}
<a href="/admin/store/submissions?{{ _common }}status={{ _needs }}" class="{{ 'active' if status_filter == _needs else '' }}">Needs review</a>
<a href="/admin/store/submissions?{{ _common }}status=approved" class="{{ 'active' if status_filter == 'approved' else '' }}">Approved</a>
<a href="/admin/store/submissions?{{ _common }}status=overridden" class="{{ 'active' if status_filter == 'overridden' else '' }}">Overridden</a>
<a href="/admin/store/submissions?{{ _common }}status=archived" class="{{ 'active' if status_filter == 'archived' else '' }}">Archived</a>
<a href="/admin/store/submissions?{{ _common }}status=deleted" class="{{ 'active' if status_filter == 'deleted' else '' }}">Deleted</a>
</div>
<div class="subs-table-wrap">
{% if items %}
<table class="subs-table">
<thead>
<tr>
<th>{{ sort_link("created_at", "When") }}</th>
<th>Submitter</th>
<th>Type / {{ sort_link("name", "name") }}</th>
<th title="Human version index (v1, v2, …) derived from the entity's version_history. Hash shown below.">v#</th>
<th>Hash</th>
<th>{{ sort_link("status", "Status") }}</th>
<th>{{ sort_link("file_size", "Size") }}</th>
<th>Findings</th>
<th>Model</th>
</tr>
</thead>
<tbody>
{% for s in items %}
<tr data-href="/admin/store/submissions/{{ s.id }}">
<td class="ts">{{ s.created_at.strftime("%Y-%m-%d %H:%M:%S") if s.created_at else "" }}</td>
<td class="submitter">
{# Filter-by-submitter link. stopPropagation in JS so row click is suppressed when clicking the email. #}
<a href="?{{ base_qs }}submitter={{ s.submitter_id }}" data-noprop="1">{{ s.submitter_email or s.submitter_id }}</a>
</td>
<td>
<span style="color: var(--text-secondary, #6b7280); font-size: 11px;">{{ s.type }}</span>
<strong>{{ s.name | store_display_name }}</strong>
</td>
<td style="white-space: nowrap; font-variant-numeric: tabular-nums;">
{%- if s.version_no -%}
<strong>v{{ s.version_no }}</strong>
{%- if s.entity_version_no and s.entity_version_no == s.version_no %}
<span style="display:inline-block;padding:1px 5px;margin-left:4px;border-radius:999px;background:#d1fae5;color:#065f46;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.3px;">current</span>
{%- endif %}
{%- else -%}
<span style="color: var(--text-secondary, #9ca3af);"></span>
{%- endif -%}
</td>
<td><code style="font-size:11px;">{{ s.version[:12] if s.version else "" }}</code></td>
<td><span class="badge {{ s.status }}">{{ s.status }}</span></td>
<td style="white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--text-secondary, #6b7280);">{{ fmt_size(s.file_size) }}</td>
<td class="findings">
{%- if s.inline_checks and s.inline_checks.static_security and s.inline_checks.static_security.findings -%}
{%- for f in s.inline_checks.static_security.findings %}
[{{ f.severity }}] {{ f.file }}:{{ f.line }} — {{ f.reason }}
{%- endfor %}
{%- endif -%}
{%- if s.inline_checks and s.inline_checks.manifest and s.inline_checks.manifest.issues %}
manifest: {{ s.inline_checks.manifest.issues | join(", ") }}
{%- endif -%}
{%- if s.llm_findings and s.llm_findings.summary %}
llm: {{ s.llm_findings.risk_level }} — {{ s.llm_findings.summary }}
{%- endif -%}
{%- if s.llm_findings and s.llm_findings.error %}
error: {{ s.llm_findings.error }}
{%- endif -%}
</td>
<td><span style="font-size: 11px; color: var(--text-secondary, #6b7280);">{{ s.reviewed_by_model or "" }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="subs-paging">
<div>
{% set first = skip + 1 if total else 0 %}
{% set last = skip + items|length %}
Showing {{ first }}{{ last }} of {{ total }}
</div>
<div>
{% set prev_skip = (skip - limit) if (skip - limit) > 0 else 0 %}
{% if skip > 0 %}
<a href="?{{ base_qs }}limit={{ limit }}&skip={{ prev_skip }}"> Prev</a>
{% else %}
<span class="disabled"> Prev</span>
{% endif %}
page {{ current_page }} of {{ pages }}
{% if current_page < pages %}
<a href="?{{ base_qs }}limit={{ limit }}&skip={{ skip + limit }}">Next </a>
{% else %}
<span class="disabled">Next </span>
{% endif %}
</div>
</div>
{% else %}
<div class="empty">No submissions match the current filter.</div>
{% endif %}
</div>
</div>
<script>
(function () {
// Row click → detail page; suppress when clicking the submitter-filter link.
document.querySelectorAll('.subs-table tbody tr[data-href]').forEach((tr) => {
tr.addEventListener('click', (ev) => {
if (ev.target.closest('a[data-noprop]')) return;
window.location.href = tr.dataset.href;
});
});
// If any visible row is pending_*, auto-refresh the list every 5s so
// admins watching the queue see verdicts land without a manual reload.
// Stops once no pending rows remain.
const hasPending = !!document.querySelector(
'.badge.pending_inline, .badge.pending_llm'
);
if (hasPending) {
setTimeout(() => window.location.reload(), 5000);
}
})();
</script>
{% endblock %}