* 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>
348 lines
16 KiB
HTML
348 lines
16 KiB
HTML
{% 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 %}
|