agnes-the-ai-analyst/app/web/templates/_flea_versions.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

152 lines
5.3 KiB
HTML

{# Versions card — owner + admin only.
Renders entity.version_history (oldest-first) reversed so newest
appears at top. Each row gets:
* version label (vN, "current" badge for the active one)
* short hash + size + created_at
* Restore button (owner + admin) for non-current versions
* Download button (admin only) — links to admin submission bundle
Required scope:
entity — store_entities row carrying version_no + version_history
is_owner — bool
is_admin — bool
Self-guards on visibility (only renders for owner/admin) and on
history length (>= 1). Plain entities created post-v37 always have
at least a v1 entry.
#}
{% if (is_owner or is_admin) and entity and entity.version_history and entity.version_history|length >= 1 %}
<style>
.versions-card {
margin: 16px 0; padding: 14px 18px; background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb); border-radius: 10px;
font-size: 13px;
}
.versions-card h3 {
margin: 0 0 10px 0; font-size: 14px; font-weight: 600;
color: var(--text, #111827);
}
.versions-card table { width: 100%; border-collapse: collapse; }
.versions-card th, .versions-card td {
text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border-light, #f3f4f6);
vertical-align: middle;
}
.versions-card th {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--text-secondary, #6b7280); font-weight: 500;
}
.versions-card .vn { font-weight: 600; }
.versions-card .current-badge {
display: inline-block; padding: 1px 6px; border-radius: 999px;
background: #d1fae5; color: #065f46; font-size: 10px;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
margin-left: 6px;
}
.versions-card code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; background: rgba(0,0,0,0.04); padding: 1px 4px; border-radius: 3px;
}
.versions-card button.restore {
padding: 4px 10px; border-radius: 5px;
border: 1px solid var(--border, #d1d5db); background: var(--surface, #fff);
font-size: 12px; cursor: pointer;
}
.versions-card button.restore:hover { background: var(--surface-muted, #f9fafb); }
.versions-card button.restore:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
<div class="versions-card">
<h3>Versions ({{ entity.version_history|length }})</h3>
<table>
<thead>
<tr>
<th>Version</th>
<th>Hash</th>
<th>Size</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{# Render newest-first. Build a reversed list in Jinja. #}
{% set ordered = entity.version_history | sort(attribute='n', reverse=true) %}
{% for v in ordered %}
<tr>
<td class="vn">
v{{ v.n }}
{% if v.n == entity.version_no %}<span class="current-badge">current</span>{% endif %}
</td>
<td><code>{{ v.hash[:12] if v.hash else '—' }}</code></td>
<td>
{%- if v.size is not none -%}
{%- if v.size < 1024 -%}{{ v.size }} B
{%- elif v.size < 1048576 -%}{{ "%.1f"|format(v.size / 1024) }} KB
{%- else -%}{{ "%.1f"|format(v.size / 1048576) }} MB
{%- endif -%}
{%- else -%}{%- endif -%}
</td>
<td style="white-space: nowrap; color: var(--text-secondary, #6b7280);">
{{ v.created_at[:10] if v.created_at else '' }}
</td>
<td>
{% if v.n != entity.version_no %}
<button class="restore" type="button"
data-version-no="{{ v.n }}"
onclick="restoreVersion({{ v.n }})">
Restore
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
async function restoreVersion(versionNo) {
const currentVersionNo = {{ entity.version_no | tojson }};
const ok = confirm(
`Restore version ${versionNo}?\n\n` +
`This will create a new version (v${currentVersionNo + 1}) with v${versionNo}'s ` +
`bundle and re-run security checks. The current version stays in history.`
);
if (!ok) return;
const r = await fetch(
`/api/store/entities/{{ entity.id }}/versions/${versionNo}/restore`,
{method: 'POST', credentials: 'same-origin'}
);
if (r.ok) {
window.location.reload();
return;
}
let msg = 'Restore failed.';
try {
const j = await r.json();
if (j.detail) {
const code = j.detail.code || '';
if (code === 'submission_blocked') {
// Land on detail to see the blocked banner.
const eid = j.detail.entity_id || '{{ entity.id }}';
window.location = `/marketplace/flea/${eid}`;
return;
}
if (code === 'prior_version_pending') {
msg = 'A previous edit is still under review. Wait for the verdict before restoring.';
} else if (code === 'version_not_found') {
msg = 'That version is no longer on disk.';
} else if (code === 'already_current') {
msg = 'Already on that version.';
} else if (typeof j.detail === 'object') {
msg = `Restore failed: ${code || JSON.stringify(j.detail)}`;
} else {
msg = `Restore failed: ${j.detail}`;
}
}
} catch (_) {}
alert(msg);
}
</script>
{% endif %}