mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
feat(hub): every update to a listed tool waits for treg's review
Jason: a maker could get a tool approved, then publish a version that charges more or returns
worse data, and it would serve every search caller at once (docs/hub-listing-decisions.md round 4,
which replaces round 2's "an approval stays across new versions").
- A version that passes its check on an approved tool becomes `review`, not `live`, so every
surface that reads `live` (calls by id, search, the place beside providers, the share page) keeps
the approved version. A newer waiting version makes the older one `superseded`. The maker's team
can call a waiting version by `<id>@N`; nobody else can.
- `treg hub price` on an approved tool is checked at once and stored as pending; the approved
price keeps serving.
- GET /admin/hub/updates (what serves now beside what would replace it) and POST
/admin/hub/updates/{id} {decision, reason}: approve makes the version live and applies the
price; reject marks the version `rejected`, drops the price and gives the maker the reason.
- Unlisting, or a rejected listing, releases what waits: an unlisted tool is self-serve.
- HubListing gains pending_version, pending_pricing, update_reason (in the unreleased 0050).
- CLI, the dashboard's Listing tab and a new "Hub updates waiting" queue on the Admin page say
it; skill.md, llms.txt, USAGE.md; plugins and surface snapshots regenerated.
Tests: a new version waits while v1 serves and only the maker can pin v2; approve serves v2;
reject keeps v1 with a reason; superseded; a price change waits and serves after approval;
unlisting releases both.
Updates docs/context/architecture/hub.md.
This commit is contained in:
@@ -508,10 +508,13 @@ treg hub log <id> --public on|off show or hide the run log on the shar
|
||||
approves it, the tool is in `treg catalog search` and `catalog_search`, marked `hub` with your team,
|
||||
the price label and the 30-day success rate, ranked by relevance with no boost. A rejection comes
|
||||
with a reason (`treg hub ls`, and the dashboard's Listing tab); listing again asks again. An
|
||||
approval belongs to the tool and stays across new versions; `unlist` withdraws it. Add
|
||||
listing belongs to the tool; `unlist` withdraws it. Add
|
||||
`"capability": "<a catalog capability id>"` to recipe.json to name the job your tool does: once
|
||||
approved, `treg catalog get` on any provider of that job shows your tool beside them. A new tool's
|
||||
success rate starts at 90%, counted as 5 runs, and real runs by other teams replace that estimate. Unlisted,
|
||||
success rate starts at 90%, counted as 5 runs, and real runs by other teams replace that estimate.
|
||||
Once a tool is listed, every new version and every `treg hub price` waits for treg's review: the
|
||||
publish answers `review`, callers keep the approved version and price, and you can call the new
|
||||
version yourself as `<id>@N`. A rejected update keeps the approved one and tells you why. Unlisted,
|
||||
unapproved, failed and retired tools never appear. The share page's run log shows the last 20 runs and runs per
|
||||
day for 30 days: time, outcome, duration, steps, units and the price paid, and never who called,
|
||||
the inputs, or the output. The dashboard has the same two switches under Hub → a tool → Listing.
|
||||
|
||||
@@ -295,7 +295,7 @@ bump; a script's amounts change only with a new version of run.js.
|
||||
rate from runs by others; unlisted, unapproved, failed and retired never appear.
|
||||
- **The listing review** (`docs/hub-listing-decisions.md` round 2, 2026-09-24): `PATCH
|
||||
/hub/tools/{id} {"listed": true}` (`treg hub list`) is a request, a `HubListing` row, one per
|
||||
tool and not per version, so an approval stays across new versions (migration 0050; the old
|
||||
tool and not per version, so the listing stays while each new version is reviewed (below; migration 0050; the old
|
||||
per-version `hubtool.listed` column stays unread, expand-only). `set_flags` creates it in state
|
||||
`requested`, asks again from `rejected`, leaves `requested` and `approved` alone; `listed: false`
|
||||
deletes it. A superadmin reads the queue (`GET /admin/hub/listings?state=`, `pending_listings`:
|
||||
@@ -313,6 +313,17 @@ bump; a script's amounts change only with a new version of run.js.
|
||||
(`seeded_observed`: 90% counted as 5 runs, blended with the last 30 days of runs by other teams,
|
||||
`estimated` under 20 runs), because a new tool has nothing to compare. It is never a routed child:
|
||||
an agent compares and picks (non-negotiable 4).
|
||||
- **Updates to a listed tool** (round 4): a version that passes its check on an APPROVED tool
|
||||
becomes `review`, not `live` (`_hold_for_review`), so every surface that reads `live` keeps the
|
||||
approved version; a newer waiting version makes the older one `superseded`. `tool_for` serves a
|
||||
pinned `review` version to the maker's team only. `treg hub price` on an approved tool is checked
|
||||
and stored as `HubListing.pending_pricing`. The queue is `GET /admin/hub/updates`
|
||||
(`pending_updates`: what serves now beside what would replace it); `POST
|
||||
/admin/hub/updates/{id} {decision, reason}` (`decide_update`) approves (the version goes live, the
|
||||
price applies) or rejects (the version becomes `rejected`, the price is dropped, the maker reads
|
||||
`listing.update.reason`). Unlisting or a rejected listing releases what waits
|
||||
(`_release_update`): an unlisted tool is self-serve. The approved capability stays across an
|
||||
approved update; the admin changes it by approving the listing again.
|
||||
- **The public share page** `GET /hub/<id>` (and `.md`; `@N`): the contract for a person or an
|
||||
agent on the public stylesheet; the price as the mode and the worst case ("seller $X per unit,
|
||||
up to $Y per run"; the schema.org Offer carries the worst case); the RUN LOG when the maker left
|
||||
|
||||
@@ -61,3 +61,20 @@ and runs by other teams move it; after about 20 the seed barely counts. Until th
|
||||
marked `estimated`. The owner chose a seed over hiding new tools: hidden, a tool never gets the runs
|
||||
that would prove it.
|
||||
|
||||
## Round 4 — every update to a listed tool goes through treg (2026-09-24, owner + Jason)
|
||||
|
||||
Jason: a maker could get a tool approved, then publish a version that charges more or returns
|
||||
worse data. Decided:
|
||||
|
||||
**1. Any update to a listed tool waits for review:** a new version and a price change. The approved
|
||||
version and price keep serving callers, search and the place beside providers until treg approves.
|
||||
This replaces round 2's "an approval stays across new versions".
|
||||
|
||||
**2. The maker can still try it.** A waiting version answers `<id>@N` for the maker's team only.
|
||||
|
||||
**3. A rejection keeps the approved version,** marks the new one `rejected`, drops the new price,
|
||||
and gives the maker the reason.
|
||||
|
||||
**4. Unlisted tools stay self-serve.** A tool nobody can find publishes and prices without review;
|
||||
unlisting releases whatever was waiting.
|
||||
|
||||
|
||||
@@ -310,7 +310,9 @@ once treg approves the request it appears in `catalog_search` too, marked `kind:
|
||||
relevance like any endpoint (`treg hub ls` shows where the request stands, and a rejection's reason).
|
||||
Name the job in recipe.json, `"capability": "people.email.find"` (a capability id from
|
||||
`treg catalog search`): once approved, `catalog_get` on any provider of that job lists your tool
|
||||
beside them, with a success rate that starts at an estimate and follows real runs.
|
||||
beside them, with a success rate that starts at an estimate and follows real runs. Once listed,
|
||||
every new version and every price change waits for treg's review: `treg hub publish` answers
|
||||
`review`, callers keep the approved version, and you can try the new one yourself as `<id>@N`.
|
||||
|
||||
**First, check this registry HAS the hub.** It is a per-deployment switch, and it is off by default.
|
||||
When it is off every `/hub/...` route answers `404` and every `treg hub` command refuses. That is
|
||||
|
||||
@@ -33,6 +33,23 @@ export default { setup: useDashboard }
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="grp" style="margin:18px 0 8px">Hub updates waiting <span class="muted">(listed tools: a new version or a new price)</span></div>
|
||||
<p v-if="!admHub.updates.length" class="sub">None waiting.</p>
|
||||
<table v-else>
|
||||
<tr><th>Tool</th><th>Serves now</th><th>Would replace it</th><th></th></tr>
|
||||
<tr v-for="u in admHub.updates" :key="'u'+u.tool_id">
|
||||
<td><b>{{u.tool_id}}</b></td>
|
||||
<td class="sub">v{{u.now&&u.now.version}} · {{u.now&&u.now.price_label}}<div style="max-width:40ch">{{u.now&&u.now.summary}}</div></td>
|
||||
<td class="sub"><template v-if="u.new">v{{u.new.version}} · {{u.new.price_label}} · check {{u.new.check}}<div style="max-width:40ch">{{u.new.summary}}</div>
|
||||
<div v-if="u.now && JSON.stringify(u.now.uses)!==JSON.stringify(u.new.uses)">uses: {{u.new.uses.join(', ')}}</div></template>
|
||||
<div v-if="u.new_price_usd!=null"><b>new price: ${{u.new_price_usd}}</b></div></td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<input v-model="admHub.reason['u:'+u.tool_id]" placeholder="reason, to reject" style="width:160px" aria-label="Reason to reject the update"/>
|
||||
<button class="btn sm primary" :disabled="admHub.busy===u.tool_id" @click="admHubUpdate(u,'approve')" style="margin-left:6px">Approve</button>
|
||||
<button class="btn sm" :disabled="admHub.busy===u.tool_id" @click="admHubUpdate(u,'reject')" style="margin-left:6px">Reject</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="height:22px"></div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ POST {{proxy}}/call/{{hub.tool.tool_id}} X-Treg-Token · JSON body of inputs<
|
||||
</template>
|
||||
|
||||
<template v-if="hub.tab==='listing'">
|
||||
<p class="sub">Neither choice bumps the version.</p>
|
||||
<p class="sub">Neither choice bumps the version. Once listed, every new version and price change waits for treg's review.</p>
|
||||
<div style="margin:14px 0 4px">
|
||||
<div class="lbl">In catalog search</div>
|
||||
<p class="sub" style="margin:4px 0;max-width:70ch">
|
||||
@@ -118,9 +118,15 @@ POST {{proxy}}/call/{{hub.tool.tool_id}} X-Treg-Token · JSON body of inputs<
|
||||
<template v-else-if="hub.tool.capability">Proposed job: <code>{{hub.tool.capability}}</code>. Once approved, your tool sits beside that job's providers.</template>
|
||||
<template v-else>No job named. Add <code>"capability"</code> to recipe.json (a capability id from <code>treg catalog search</code>) to sit beside that job's providers once approved.</template>
|
||||
</p>
|
||||
<p v-if="hub.tool.listing && hub.tool.listing.update" class="sub" style="margin:4px 0;max-width:70ch">
|
||||
<template v-if="hub.tool.listing.update.state==='pending'"><b class="warn">An update waits for treg's review:</b>
|
||||
<template v-if="hub.tool.listing.update.version"> version {{hub.tool.listing.update.version}}</template><template v-if="hub.tool.listing.update.version && hub.tool.listing.update.pricing"> and</template>
|
||||
<template v-if="hub.tool.listing.update.pricing"> the price ${{hub.tool.listing.update.pricing.price_usd}}</template>. Callers keep the approved one until then.</template>
|
||||
<template v-else><b class="warn">Your last update was rejected:</b> {{hub.tool.listing.update.reason}}{{/[.!?]$/.test(hub.tool.listing.update.reason)?'':'.'}} The approved version still serves.</template>
|
||||
</p>
|
||||
<button v-if="['none','rejected'].includes(hubListing(hub.tool))" class="btn sm primary" :disabled="hub.flagSaving||!canRegister||hub.tool.status!=='live'" @click="setHubFlag('listed',true)">{{hubListing(hub.tool)==='rejected'?'Ask again':'Ask to list it'}}</button>
|
||||
<button v-else class="btn sm" :disabled="hub.flagSaving||!canRegister" @click="setHubFlag('listed',false)">{{hubListing(hub.tool)==='approved'?'Unlist':'Withdraw the request'}}</button>
|
||||
<p class="sub" style="margin:6px 0 0;max-width:70ch">Listed: it appears in catalog search and <code>catalog_search</code>, marked as a hub tool by your team, ranked by relevance with no boost. treg reviews each request; an approval stays when you publish a new version. Not listed: only someone with the id or the share link can call it.</p>
|
||||
<p class="sub" style="margin:6px 0 0;max-width:70ch">Listed: it appears in catalog search and <code>catalog_search</code>, marked as a hub tool by your team, ranked by relevance with no boost. treg reviews each request, and each later version or price change. Not listed: only someone with the id or the share link can call it.</p>
|
||||
</div>
|
||||
<div style="margin:14px 0 4px">
|
||||
<label class="tgl" style="font-size:13.5px"><input type="checkbox" :checked="hub.tool.public_log!==false" :disabled="hub.flagSaving||!canRegister||hub.tool.status!=='live'" @change="setHubFlag('public_log',$event.target.checked)"/><span><b>Public run log on the share page</b></span></label>
|
||||
|
||||
@@ -7,8 +7,16 @@ async loadAdmin(){ this.confirmAdmUser=null; this.confirmAdmOrg=null;
|
||||
// Hub listing review: a maker's `treg hub list` is a request; approve puts the tool in search,
|
||||
// reject takes it out with a reason the maker reads. 404 = the hub is off here: no section.
|
||||
async loadAdminHub(state){ if(state) this.admHub={...this.admHub, state};
|
||||
try{ const rows=await this.api('/admin/hub/listings?state='+this.admHub.state); this.admHub={...this.admHub, on:true, rows}; }
|
||||
try{ const rows=await this.api('/admin/hub/listings?state='+this.admHub.state);
|
||||
const updates=await this.api('/admin/hub/updates').catch(()=>[]);
|
||||
this.admHub={...this.admHub, on:true, rows, updates}; }
|
||||
catch(e){ this.admHub={...this.admHub, on:false, rows:[]}; } },
|
||||
async admHubUpdate(u, decision){ const reason=(this.admHub.reason['u:'+u.tool_id]||'').trim();
|
||||
if(decision==='reject' && !reason){ this.err='Write the reason first: the maker reads it.'; return; }
|
||||
this.admHub={...this.admHub, busy:u.tool_id}; this.err='';
|
||||
try{ await this.api('/admin/hub/updates/'+encodeURIComponent(u.tool_id), {method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({decision, reason})}); }
|
||||
catch(e){ this.err='Update decision failed: '+(e.detail&&e.detail.rule||e.detail||e.status); }
|
||||
this.admHub={...this.admHub, busy:null}; await this.loadAdminHub(); },
|
||||
async admHubDecide(r, decision){ const reason=(this.admHub.reason[r.tool_id]||'').trim();
|
||||
if(decision==='reject' && !reason){ this.err='Write the reason first: the maker reads it.'; return; }
|
||||
this.admHub={...this.admHub, busy:r.tool_id}; this.err='';
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function data(){
|
||||
welcome:{on:false, step:0, name:'', agent:'openclaw', moreOpen:false, busy:false, err:''}, // first-run: name your team → pick your agent → setup line
|
||||
emptyTab:'agent',
|
||||
tools:[], health:{}, calls:[], runs:[], adminStats:null, adminOrgs:[], adminUsers:[],
|
||||
admHub:{on:false, state:'requested', rows:[], reason:{}, cap:{}, busy:null}, // hub listing review (superadmin)
|
||||
admHub:{on:false, state:'requested', rows:[], reason:{}, cap:{}, busy:null, updates:[]}, // hub listing review (superadmin)
|
||||
adminBusy:false, confirmAdmUser:null, confirmAdmOrg:null,
|
||||
proxy: location.origin, copyTool:null, snippetTab:'cURL', snippetTabs:['cURL','CLI','Claude Code','Python','Node'], copied:false,
|
||||
exPath:'<PATH>', exMethod:'GET',
|
||||
|
||||
@@ -313,7 +313,9 @@ once treg approves the request it appears in `catalog_search` too, marked `kind:
|
||||
relevance like any endpoint (`treg hub ls` shows where the request stands, and a rejection's reason).
|
||||
Name the job in recipe.json, `"capability": "people.email.find"` (a capability id from
|
||||
`treg catalog search`): once approved, `catalog_get` on any provider of that job lists your tool
|
||||
beside them, with a success rate that starts at an estimate and follows real runs.
|
||||
beside them, with a success rate that starts at an estimate and follows real runs. Once listed,
|
||||
every new version and every price change waits for treg's review: `treg hub publish` answers
|
||||
`review`, callers keep the approved version, and you can try the new one yourself as `<id>@N`.
|
||||
|
||||
**First, check this registry HAS the hub.** It is a per-deployment switch, and it is off by default.
|
||||
When it is off every `/hub/...` route answers `404` and every `treg hub` command refuses. That is
|
||||
|
||||
@@ -294,7 +294,9 @@ once treg approves the request it appears in `catalog_search` too, marked `kind:
|
||||
relevance like any endpoint (`treg hub ls` shows where the request stands, and a rejection's reason).
|
||||
Name the job in recipe.json, `"capability": "people.email.find"` (a capability id from
|
||||
`treg catalog search`): once approved, `catalog_get` on any provider of that job lists your tool
|
||||
beside them, with a success rate that starts at an estimate and follows real runs.
|
||||
beside them, with a success rate that starts at an estimate and follows real runs. Once listed,
|
||||
every new version and every price change waits for treg's review: `treg hub publish` answers
|
||||
`review`, callers keep the approved version, and you can try the new one yourself as `<id>@N`.
|
||||
|
||||
**First, check this registry HAS the hub.** It is a per-deployment switch, and it is off by default.
|
||||
When it is off every `/hub/...` route answers `404` and every `treg hub` command refuses. That is
|
||||
|
||||
@@ -308,7 +308,9 @@ once treg approves the request it appears in `catalog_search` too, marked `kind:
|
||||
relevance like any endpoint (`treg hub ls` shows where the request stands, and a rejection's reason).
|
||||
Name the job in recipe.json, `"capability": "people.email.find"` (a capability id from
|
||||
`treg catalog search`): once approved, `catalog_get` on any provider of that job lists your tool
|
||||
beside them, with a success rate that starts at an estimate and follows real runs.
|
||||
beside them, with a success rate that starts at an estimate and follows real runs. Once listed,
|
||||
every new version and every price change waits for treg's review: `treg hub publish` answers
|
||||
`review`, callers keep the approved version, and you can try the new one yourself as `<id>@N`.
|
||||
|
||||
**First, check this registry HAS the hub.** It is a per-deployment switch, and it is off by default.
|
||||
When it is off every `/hub/...` route answers `404` and every `treg hub` command refuses. That is
|
||||
|
||||
@@ -292,7 +292,9 @@ once treg approves the request it appears in `catalog_search` too, marked `kind:
|
||||
relevance like any endpoint (`treg hub ls` shows where the request stands, and a rejection's reason).
|
||||
Name the job in recipe.json, `"capability": "people.email.find"` (a capability id from
|
||||
`treg catalog search`): once approved, `catalog_get` on any provider of that job lists your tool
|
||||
beside them, with a success rate that starts at an estimate and follows real runs.
|
||||
beside them, with a success rate that starts at an estimate and follows real runs. Once listed,
|
||||
every new version and every price change waits for treg's review: `treg hub publish` answers
|
||||
`review`, callers keep the approved version, and you can try the new one yourself as `<id>@N`.
|
||||
|
||||
**First, check this registry HAS the hub.** It is a per-deployment switch, and it is off by default.
|
||||
When it is off every `/hub/...` route answers `404` and every `treg hub` command refuses. That is
|
||||
|
||||
@@ -5,9 +5,11 @@ Revises: 0049
|
||||
Create Date: 2026-09-24
|
||||
|
||||
One row per tool (docs/hub-listing-decisions.md round 2): state requested | approved | rejected,
|
||||
so an approval outlives a version. Expand only: `hubtool.listed` stays in the table, unread from this
|
||||
so a listing outlives a version. Expand only: `hubtool.listed` stays in the table, unread from this
|
||||
revision on (the hub was off in production, so no tool was listed there), for a later contract
|
||||
revision to drop. `capability` is the catalog job treg approved with the listing (round 3).
|
||||
revision to drop. `capability` is the catalog job treg approved with the listing (round 3); `pending_version`,
|
||||
`pending_pricing` and `update_reason` hold an update to an approved tool while it waits for review
|
||||
(round 4).
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
@@ -32,6 +34,9 @@ def upgrade() -> None:
|
||||
sa.Column("decided_by", sa.String(), nullable=False, server_default=""),
|
||||
sa.Column("decided_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("capability", sa.String(), nullable=False, server_default=""),
|
||||
sa.Column("pending_version", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("pending_pricing", sa.JSON(), nullable=True),
|
||||
sa.Column("update_reason", sa.String(), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index("ix_hublisting_org_id", "hublisting", ["org_id"])
|
||||
op.create_index("ix_hublisting_state", "hublisting", ["state"])
|
||||
|
||||
@@ -81,8 +81,9 @@ async def tool_for(db: AsyncSession, rest: str, *, live_only: bool = True,
|
||||
row = (await db.execute(select(HubTool).where(HubTool.tool_id == tool_id, HubTool.version == pin))).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
if row.status == "checking":
|
||||
# the check run pins it (round 2 q10); anyone else who guesses @N gets nothing (8.1 review)
|
||||
if row.status in ("checking", "review"):
|
||||
# the check run pins it (round 2 q10); a version waiting for review (round 4) is the maker's to
|
||||
# try by @N and nobody else's; anyone else who guesses @N gets nothing (8.1 review)
|
||||
return row if caller_org_id is None or row.org_id == caller_org_id else None
|
||||
if live_only and row.status != "live":
|
||||
return None
|
||||
@@ -205,6 +206,8 @@ async def run_check(db: AsyncSession, row: HubTool, *, maker_headers: dict[str,
|
||||
body = {"text": r.text[:600]}
|
||||
verdict = verdict_from(row, r.status_code, body, dict(r.headers))
|
||||
row.status = "live" if verdict["status"] == "passed" else "failed"
|
||||
if row.status == "live":
|
||||
await _hold_for_review(db, row)
|
||||
row.check_result = verdict
|
||||
db.add(row)
|
||||
return verdict
|
||||
@@ -281,8 +284,14 @@ def listing_view(listing: HubListing | None) -> dict[str, Any]:
|
||||
admin's reason on a rejection)."""
|
||||
if listing is None:
|
||||
return {"listed": False, "listing": {"state": "none"}}
|
||||
update = None
|
||||
if listing.pending_version or listing.pending_pricing or listing.update_reason:
|
||||
update = {"state": "pending" if (listing.pending_version or listing.pending_pricing) else "rejected",
|
||||
"version": listing.pending_version or None, "pricing": listing.pending_pricing,
|
||||
"reason": listing.update_reason}
|
||||
return {"listed": listing.state == "approved",
|
||||
"listing": {"state": listing.state, "reason": listing.reason, "capability": listing.capability,
|
||||
"update": update,
|
||||
"requested_at": listing.requested_at.isoformat(),
|
||||
"decided_at": listing.decided_at.isoformat() if listing.decided_at else None}}
|
||||
|
||||
@@ -353,6 +362,22 @@ async def set_price(db: AsyncSession, *, org_id: int, tool_id: str, price_usd: f
|
||||
.order_by(HubTool.version.desc()).limit(1))).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
lst = await db.get(HubListing, tool_id)
|
||||
if lst is not None and lst.state == "approved":
|
||||
# A listed tool's price is public: the new one waits for review (round 4); check it now so
|
||||
# the maker hears a bad number at once, not from the reviewer.
|
||||
if row.kind == "script":
|
||||
hub_manifest._money_micro("max_price_usd", price_usd, allow_zero=False)
|
||||
else:
|
||||
hub_manifest._validate_price(price_usd)
|
||||
lst.pending_pricing, lst.update_reason = {"price_usd": float(price_usd)}, ""
|
||||
db.add(lst)
|
||||
return row
|
||||
return await _apply_price(db, row, price_usd)
|
||||
|
||||
|
||||
async def _apply_price(db: AsyncSession, row: HubTool, price_usd: float) -> HubTool:
|
||||
from ...domain.hub import manifest as hub_manifest
|
||||
if row.kind == "script":
|
||||
micro = hub_manifest._money_micro("max_price_usd", price_usd, allow_zero=False)
|
||||
row.manifest = {**{k: v for k, v in row.manifest.items() if k != "price_usd"},
|
||||
@@ -447,6 +472,7 @@ async def set_flags(db: AsyncSession, *, org_id: int, tool_id: str, maker_email:
|
||||
current = await db.get(HubListing, tool_id)
|
||||
if not listed:
|
||||
if current is not None:
|
||||
await _release_update(db, current) # no longer public: no longer reviewed
|
||||
await db.delete(current)
|
||||
elif current is None:
|
||||
db.add(HubListing(tool_id=tool_id, org_id=org_id, state="requested", requested_by=maker_email))
|
||||
@@ -507,6 +533,7 @@ async def decide_listing(db: AsyncSession, *, tool_id: str, approve: bool, reaso
|
||||
lst.capability = capability
|
||||
else:
|
||||
lst.capability = ""
|
||||
await _release_update(db, lst) # out of search: its update needs no review
|
||||
lst.state, lst.reason = ("approved", "") if approve else ("rejected", reason.strip()[:500])
|
||||
lst.decided_by, lst.decided_at = admin_email, utcnow_naive()
|
||||
db.add(lst)
|
||||
@@ -565,3 +592,92 @@ async def approved_capability(db: AsyncSession, tool_id: str) -> str:
|
||||
lst = await db.get(HubListing, tool_id)
|
||||
return lst.capability if lst is not None and lst.state == "approved" else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Updates to an approved tool wait for review (docs/hub-listing-decisions.md round 4): a new version
|
||||
# and a price change. The approved version and price keep serving until treg decides.
|
||||
|
||||
async def _hold_for_review(db: AsyncSession, row: HubTool) -> None:
|
||||
"""A version that passed its check becomes `review` instead of `live` when its tool is approved
|
||||
for search. A newer one replaces an older one still waiting (`superseded`)."""
|
||||
lst = await db.get(HubListing, row.tool_id)
|
||||
if lst is None or lst.state != "approved":
|
||||
return
|
||||
if lst.pending_version and lst.pending_version != row.version:
|
||||
old = (await db.execute(select(HubTool).where(HubTool.tool_id == row.tool_id,
|
||||
HubTool.version == lst.pending_version))).scalars().first()
|
||||
if old is not None and old.status == "review":
|
||||
old.status = "superseded"
|
||||
db.add(old)
|
||||
row.status = "review"
|
||||
lst.pending_version, lst.update_reason = row.version, ""
|
||||
db.add(lst)
|
||||
|
||||
|
||||
async def _release_update(db: AsyncSession, lst: HubListing) -> None:
|
||||
"""The tool left search (unlisted, or its listing rejected): what waited needs no review. The
|
||||
waiting version goes live and the waiting price applies, as for any unlisted tool."""
|
||||
if lst.pending_version:
|
||||
row = (await db.execute(select(HubTool).where(HubTool.tool_id == lst.tool_id,
|
||||
HubTool.version == lst.pending_version))).scalars().first()
|
||||
if row is not None and row.status == "review":
|
||||
row.status = "live"
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
if lst.pending_pricing:
|
||||
row = (await db.execute(select(HubTool).where(HubTool.tool_id == lst.tool_id, HubTool.status == "live")
|
||||
.order_by(HubTool.version.desc()).limit(1))).scalars().first()
|
||||
if row is not None:
|
||||
await _apply_price(db, row, lst.pending_pricing["price_usd"])
|
||||
lst.pending_version, lst.pending_pricing, lst.update_reason = 0, None, ""
|
||||
db.add(lst)
|
||||
|
||||
|
||||
async def pending_updates(db: AsyncSession) -> list[dict[str, Any]]:
|
||||
"""The review queue of updates: every approved tool with a version or a price waiting, each with
|
||||
what serves now beside what would replace it, so the reviewer reads the change."""
|
||||
rows = (await db.execute(select(HubListing).where(HubListing.state == "approved")
|
||||
.order_by(HubListing.tool_id))).scalars().all()
|
||||
|
||||
def side(t: HubTool | None) -> dict[str, Any] | None:
|
||||
if t is None:
|
||||
return None
|
||||
return {"version": t.version, "kind": t.kind, "summary": t.summary, "price_label": price_label(t.manifest),
|
||||
"uses": t.manifest.get("uses", []), "capability": t.manifest.get("capability"),
|
||||
"check": (t.check_result or {}).get("status")}
|
||||
out = []
|
||||
for lst in rows:
|
||||
if not (lst.pending_version or lst.pending_pricing):
|
||||
continue
|
||||
now = (await db.execute(select(HubTool).where(HubTool.tool_id == lst.tool_id, HubTool.status == "live")
|
||||
.order_by(HubTool.version.desc()).limit(1))).scalars().first()
|
||||
new = None
|
||||
if lst.pending_version:
|
||||
new = (await db.execute(select(HubTool).where(HubTool.tool_id == lst.tool_id,
|
||||
HubTool.version == lst.pending_version))).scalars().first()
|
||||
out.append({"tool_id": lst.tool_id, "now": side(now), "new": side(new),
|
||||
"new_price_usd": (lst.pending_pricing or {}).get("price_usd")})
|
||||
return out
|
||||
|
||||
|
||||
async def decide_update(db: AsyncSession, *, tool_id: str, approve: bool, reason: str,
|
||||
admin_email: str) -> HubListing | None:
|
||||
"""Approve: the waiting version goes live and serves everyone; the waiting price applies to the
|
||||
version that serves. Reject: the waiting version is `rejected` (kept, never served), the waiting
|
||||
price dropped, and the maker reads the reason. None when nothing waits. Does not commit."""
|
||||
lst = await db.get(HubListing, tool_id)
|
||||
if lst is None or not (lst.pending_version or lst.pending_pricing):
|
||||
return None
|
||||
if approve:
|
||||
await _release_update(db, lst)
|
||||
else:
|
||||
if lst.pending_version:
|
||||
row = (await db.execute(select(HubTool).where(HubTool.tool_id == tool_id,
|
||||
HubTool.version == lst.pending_version))).scalars().first()
|
||||
if row is not None and row.status == "review":
|
||||
row.status = "rejected"
|
||||
db.add(row)
|
||||
lst.pending_version, lst.pending_pricing, lst.update_reason = 0, None, reason.strip()[:500]
|
||||
db.add(lst)
|
||||
return lst
|
||||
|
||||
|
||||
@@ -309,6 +309,8 @@ _CONTROL_ROUTE_KEYS: frozenset[RouteKey] = frozenset({
|
||||
('/admin/tools', ('GET',), 'admin_tools'),
|
||||
('/admin/hub/listings', ('GET',), 'admin_hub_listings'),
|
||||
('/admin/hub/listings/{tool_id}', ('POST',), 'admin_hub_listing_decide'),
|
||||
('/admin/hub/updates', ('GET',), 'admin_hub_updates'),
|
||||
('/admin/hub/updates/{tool_id}', ('POST',), 'admin_hub_update_decide'),
|
||||
('/admin/calls', ('GET',), 'admin_calls'),
|
||||
('/admin/errors', ('GET',), 'admin_errors'),
|
||||
('/admin/health', ('GET',), 'admin_health'),
|
||||
|
||||
+9
-1
@@ -5310,8 +5310,11 @@ def _hub_report(r, *, json_out: bool) -> None:
|
||||
elif r.status_code in (200, 201):
|
||||
chk = payload.get("check") or {}
|
||||
live = payload.get("status") == "live"
|
||||
_section("✓ Published" if live else "Published, but the check failed")
|
||||
review = payload.get("status") == "review"
|
||||
_section("✓ Published" if live else "✓ Published, waiting for treg's review" if review else "Published, but the check failed")
|
||||
_kv("tool", f"{_B}{payload.get('tool_id')}{_R} version {payload.get('version')} {_G if live else _AM}{payload.get('status')}{_R}")
|
||||
if review and payload.get("message"):
|
||||
_arrow(payload["message"])
|
||||
if chk:
|
||||
if chk.get("status") == "passed":
|
||||
_ok(f"check passed run {chk.get('run_id')} charged {chk.get('charged_micro', 0)} µ$ to your balance")
|
||||
@@ -5480,6 +5483,11 @@ def cmd_hub_price(args, cfg) -> None:
|
||||
if r.status_code != 200:
|
||||
_hub_report(r, json_out=getattr(args, "json", False))
|
||||
d = r.json()
|
||||
if d.get("pending_price_usd") is not None:
|
||||
_section("Price change waiting for review")
|
||||
_kv("tool", f"{d['tool_id']} v{d['version']}")
|
||||
_kv("price", f"{d.get('price_label', '')} serves now; ${d['pending_price_usd']:.6g} after treg approves it")
|
||||
return
|
||||
_section("Price changed")
|
||||
_kv("tool", f"{d['tool_id']} v{d['version']}")
|
||||
_kv("price", f"{d.get('price_label', '')}; applies to later runs")
|
||||
|
||||
+8
-1
@@ -1323,7 +1323,7 @@ class HubTool(SQLModel, table=True):
|
||||
class HubListing(SQLModel, table=True):
|
||||
"""A hub tool's place in catalog search (docs/hub-listing-decisions.md round 2, 2026-09-24): the
|
||||
maker requests it, a superadmin approves or rejects it. One row per tool, not per version, so
|
||||
an approval stays when a new version is published; unlisting deletes the row, and listing again
|
||||
the listing stays while each new version waits for its own review (round 4); unlisting deletes the row, and listing again
|
||||
is a new request. Only `approved` puts the tool in search."""
|
||||
|
||||
tool_id: str = Field(primary_key=True) # `<slug>.<name>`
|
||||
@@ -1337,6 +1337,13 @@ class HubListing(SQLModel, table=True):
|
||||
# The catalog job treg approved for it (round 3): the tool then sits beside that job's providers
|
||||
# in catalog_get. "" = in search, but beside nobody. Set only by an approval.
|
||||
capability: str = Field(default="", index=True)
|
||||
# An update to an approved tool waits for review (round 4): a new version (`pending_version`,
|
||||
# its HubTool row in status `review`) and/or a new price (`pending_pricing`, {"price_usd": N}).
|
||||
# The approved version and price keep serving until treg approves; a rejection leaves them and
|
||||
# says why in `update_reason`.
|
||||
pending_version: int = Field(default=0)
|
||||
pending_pricing: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
update_reason: str = Field(default="")
|
||||
|
||||
|
||||
class HubRun(SQLModel, table=True):
|
||||
|
||||
@@ -81,6 +81,10 @@ async def _publish(body: PublishIn, request: Request, caller: Caller, db: AsyncS
|
||||
if row.status == "live":
|
||||
out["call"] = f"POST /call/{published.tool_id}"
|
||||
out["page"] = f"{get_settings().public_url.rstrip('/')}/hub/{published.tool_id}"
|
||||
elif row.status == "review":
|
||||
out["message"] = (f"passed its check and waits for treg's review: {published.tool_id} is listed, so "
|
||||
f"callers keep the approved version until this one is approved. Try it yourself "
|
||||
f"with POST /call/{published.tool_id}@{published.version}.")
|
||||
return out
|
||||
|
||||
|
||||
@@ -392,6 +396,10 @@ async def set_hub_tool_price(
|
||||
if body.price_usd is not None:
|
||||
out["pricing"] = hub_app.stored_pricing({"price_usd": row.price_micro / 1_000_000, **row.manifest})
|
||||
out["price_label"] = hub_app.price_label(row.manifest)
|
||||
pending = (await hub_app.listings_of(db, [base])).get(base)
|
||||
if pending is not None and pending.pending_pricing:
|
||||
out["pending_price_usd"] = pending.pending_pricing["price_usd"]
|
||||
out["message"] = "the tool is listed, so the new price waits for treg's review; the price above serves until then"
|
||||
if body.listed is not None:
|
||||
out.update(hub_app.listing_view((await hub_app.listings_of(db, [base])).get(base)))
|
||||
if body.public_log is not None:
|
||||
@@ -472,3 +480,41 @@ async def admin_hub_listing_decide(
|
||||
raise HTTPException(status_code=404, detail=f"no listing request for {tool_id!r}")
|
||||
await db.commit()
|
||||
return {"tool_id": tool_id, **hub_app.listing_view(lst)}
|
||||
|
||||
|
||||
# Updates to an approved tool (round 4): a new version or a price change waits here. Approve makes
|
||||
# it serve everyone; reject keeps the approved one and tells the maker why.
|
||||
|
||||
class UpdateDecisionIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
decision: str = Field(pattern="^(approve|reject)$")
|
||||
reason: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
@app.get("/admin/hub/updates")
|
||||
async def admin_hub_updates(
|
||||
admin: str = Depends(require_superadmin), db: AsyncSession = Depends(get_admin_session),
|
||||
) -> list[dict]:
|
||||
"""The update queue: each approved tool with a version or a price waiting, what serves now
|
||||
beside what would replace it."""
|
||||
if not hub_app.enabled():
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
return await hub_app.pending_updates(db)
|
||||
|
||||
|
||||
@app.post("/admin/hub/updates/{tool_id}")
|
||||
async def admin_hub_update_decide(
|
||||
tool_id: str, body: UpdateDecisionIn, admin: str = Depends(require_superadmin),
|
||||
db: AsyncSession = Depends(get_admin_session),
|
||||
) -> dict:
|
||||
if not hub_app.enabled():
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
if body.decision == "reject" and not body.reason.strip():
|
||||
raise HTTPException(status_code=422, detail={"error": "reason_required",
|
||||
"rule": "say why, in words the maker can act on"})
|
||||
lst = await hub_app.decide_update(db, tool_id=tool_id, approve=body.decision == "approve",
|
||||
reason=body.reason, admin_email=admin)
|
||||
if lst is None:
|
||||
raise HTTPException(status_code=404, detail=f"no update waiting for {tool_id!r}")
|
||||
await db.commit()
|
||||
return {"tool_id": tool_id, **hub_app.listing_view(lst)}
|
||||
|
||||
@@ -463,9 +463,11 @@ and `200` when it is on.
|
||||
`treg catalog get`): inputs, output, price line, health, the call line.
|
||||
- **Listing and the run log** (the maker's two switches, no version bump): `treg hub list <id>` asks
|
||||
for a place in catalog search, which treg approves or rejects with a reason (`treg hub ls` shows
|
||||
the state); an approval stays across new versions; a `"capability"` in recipe.json (a catalog
|
||||
the state); a `"capability"` in recipe.json (a catalog
|
||||
capability id) puts the approved tool beside that job's providers in `catalog_get`, its success
|
||||
rate an estimate (`observed.estimated`) until about 20 runs by other teams; `treg hub unlist` takes it out; `treg hub log <id>
|
||||
rate an estimate (`observed.estimated`) until about 20 runs by other teams; once listed, every new
|
||||
version (status `review`) and every price change waits for treg's review while the approved one
|
||||
keeps serving; `treg hub unlist` takes it out; `treg hub log <id>
|
||||
--public off` hides the run log on the share page. The page's log shows the last 20 runs and runs
|
||||
per day: time, outcome, ms, steps, units, price paid — never who called, never inputs or output.
|
||||
- Versions: the newest live one serves; `<id>@N` pins one for 30 days after a newer lands.
|
||||
|
||||
@@ -278,7 +278,9 @@ once treg approves the request it appears in `catalog_search` too, marked `kind:
|
||||
relevance like any endpoint (`treg hub ls` shows where the request stands, and a rejection's reason).
|
||||
Name the job in recipe.json, `"capability": "people.email.find"` (a capability id from
|
||||
`treg catalog search`): once approved, `catalog_get` on any provider of that job lists your tool
|
||||
beside them, with a success rate that starts at an estimate and follows real runs.
|
||||
beside them, with a success rate that starts at an estimate and follows real runs. Once listed,
|
||||
every new version and every price change waits for treg's review: `treg hub publish` answers
|
||||
`review`, callers keep the approved version, and you can try the new one yourself as `<id>@N`.
|
||||
|
||||
**First, check this registry HAS the hub.** It is a per-deployment switch, and it is off by default.
|
||||
When it is off every `/hub/...` route answers `404` and every `treg hub` command refuses. That is
|
||||
|
||||
@@ -1610,6 +1610,27 @@
|
||||
"title": "TopupIn",
|
||||
"type": "object"
|
||||
},
|
||||
"UpdateDecisionIn": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"decision": {
|
||||
"pattern": "^(approve|reject)$",
|
||||
"title": "Decision",
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"default": "",
|
||||
"maxLength": 500,
|
||||
"title": "Reason",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"decision"
|
||||
],
|
||||
"title": "UpdateDecisionIn",
|
||||
"type": "object"
|
||||
},
|
||||
"UserIn": {
|
||||
"properties": {
|
||||
"email": {
|
||||
@@ -2357,6 +2378,133 @@
|
||||
"summary": "Admin Hub Listing Decide"
|
||||
}
|
||||
},
|
||||
"/admin/hub/updates": {
|
||||
"get": {
|
||||
"description": "The update queue: each approved tool with a version or a price waiting, what serves now\nbeside what would replace it.",
|
||||
"operationId": "admin_hub_updates_admin_hub_updates_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "x-treg-token",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "X-Treg-Token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "cookie",
|
||||
"name": "treg_session",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "Treg Session",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Admin Hub Updates Admin Hub Updates Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Hub Updates"
|
||||
}
|
||||
},
|
||||
"/admin/hub/updates/{tool_id}": {
|
||||
"post": {
|
||||
"operationId": "admin_hub_update_decide_admin_hub_updates__tool_id__post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "tool_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Tool Id",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "x-treg-token",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "X-Treg-Token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "cookie",
|
||||
"name": "treg_session",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "Treg Session",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateDecisionIn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"title": "Response Admin Hub Update Decide Admin Hub Updates Tool Id Post",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Admin Hub Update Decide"
|
||||
}
|
||||
},
|
||||
"/admin/orgs": {
|
||||
"get": {
|
||||
"operationId": "admin_orgs_admin_orgs_get",
|
||||
|
||||
@@ -422,6 +422,23 @@
|
||||
"name": "admin_hub_listing_decide",
|
||||
"path": "/admin/hub/listings/{tool_id}"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
"GET",
|
||||
"HEAD"
|
||||
],
|
||||
"name": "admin_hub_updates",
|
||||
"path": "/admin/hub/updates"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"name": "admin_hub_update_decide",
|
||||
"path": "/admin/hub/updates/{tool_id}"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
|
||||
+86
-5
@@ -1051,18 +1051,99 @@ async def test_a_rejection_carries_a_reason_and_listing_again_asks_again(clients
|
||||
assert (await _decide(clients, monkeypatch, tool_id, "approve")).status_code == 404
|
||||
|
||||
|
||||
async def test_an_approval_stays_when_a_new_version_is_published(clients: AsyncClient, hub_on, platform_on, monkeypatch):
|
||||
async def _v2(clients, tool_id, summary="Decision makers of a company, with verified emails, now faster."):
|
||||
m = _steps_manifest(steps=[{"name": "people", "call": EP, "input": {"aweme_id": "$input.domain"}}],
|
||||
output={"leads": "$people.data"}, summary=summary)
|
||||
return await clients.put(f"/hub/tools/{tool_id}", json={"manifest": m, "check": CHECK, "readme": "x"})
|
||||
|
||||
|
||||
async def _decide_update(clients, monkeypatch, tool_id, decision, reason=""):
|
||||
monkeypatch.setenv("TREG_ADMIN_TOKEN", ADMIN)
|
||||
get_settings.cache_clear()
|
||||
return await clients.post(f"/admin/hub/updates/{tool_id}", json={"decision": decision, "reason": reason},
|
||||
headers={"X-Treg-Token": ADMIN})
|
||||
|
||||
|
||||
async def test_a_new_version_of_a_listed_tool_waits_for_review(clients: AsyncClient, hub_on, platform_on, monkeypatch):
|
||||
"""Round 4 (Jason): every update to a listed tool goes through treg. v2 passes its check and
|
||||
waits; v1 keeps serving callers and search; only the maker can try v2 by @2."""
|
||||
pub = await _live_tool_with_readme(clients, monkeypatch, price=0.01)
|
||||
tool_id = pub["tool_id"]
|
||||
await clients.patch(f"/hub/tools/{tool_id}", json={"listed": True})
|
||||
await _decide(clients, monkeypatch, tool_id, "approve")
|
||||
m = _steps_manifest(steps=[{"name": "people", "call": EP, "input": {"aweme_id": "$input.domain"}}],
|
||||
output={"leads": "$people.data"}, summary="Decision makers of a company, with verified emails, now faster.")
|
||||
r = await clients.put(f"/hub/tools/{tool_id}", json={"manifest": m, "check": CHECK, "readme": "x"})
|
||||
assert r.status_code == 200 and r.json()["version"] == 2 and r.json()["status"] == "live", r.text
|
||||
r = await _v2(clients, tool_id)
|
||||
assert r.status_code == 200 and r.json()["status"] == "review" and "waits for treg" in r.json()["message"]
|
||||
assert (await clients.get(f"/catalog/endpoints/{tool_id}")).json()["endpoint"]["version"] == 1
|
||||
assert tool_id in await _search_ids(clients) # still listed, as v1
|
||||
stranger = {"X-Treg-Token": (await funded_user(clients, "v2-stranger@example.com"))["token"]}
|
||||
run = await clients.post(f"/call/{tool_id}", json={"domain": "x"}, headers=stranger)
|
||||
assert run.status_code == 200 and run.json()["recipe"] == f"{tool_id}@1"
|
||||
assert (await clients.post(f"/call/{tool_id}@2", json={"domain": "x"}, headers=stranger)).status_code == 404
|
||||
assert (await clients.post(f"/call/{tool_id}@2", json={"domain": "x"})).status_code == 200 # the maker
|
||||
queue = (await clients.get("/admin/hub/updates", headers={"X-Treg-Token": ADMIN})).json()
|
||||
assert queue[0]["tool_id"] == tool_id and queue[0]["now"]["version"] == 1 and queue[0]["new"]["version"] == 2
|
||||
assert queue[0]["new"]["summary"].endswith("now faster.")
|
||||
r = await _decide_update(clients, monkeypatch, tool_id, "approve")
|
||||
assert r.status_code == 200 and r.json()["listing"]["update"] is None
|
||||
run = await clients.post(f"/call/{tool_id}", json={"domain": "x"}, headers=stranger)
|
||||
assert run.json()["recipe"] == f"{tool_id}@2"
|
||||
assert tool_id in await _search_ids(clients)
|
||||
|
||||
|
||||
async def test_a_rejected_update_keeps_the_approved_version(clients: AsyncClient, hub_on, platform_on, monkeypatch):
|
||||
pub = await _live_tool_with_readme(clients, monkeypatch, price=0.01)
|
||||
tool_id = pub["tool_id"]
|
||||
await clients.patch(f"/hub/tools/{tool_id}", json={"listed": True})
|
||||
await _decide(clients, monkeypatch, tool_id, "approve")
|
||||
await _v2(clients, tool_id)
|
||||
assert (await _decide_update(clients, monkeypatch, tool_id, "reject")).status_code == 422 # no reason
|
||||
r = await _decide_update(clients, monkeypatch, tool_id, "reject", "v2 drops the verified emails")
|
||||
assert r.json()["listing"]["update"] == {"state": "rejected", "version": None, "pricing": None,
|
||||
"reason": "v2 drops the verified emails"}
|
||||
mine = {t["version"]: t["status"] for t in (await clients.get("/hub/tools/mine")).json() if t["tool_id"] == tool_id}
|
||||
assert mine == {1: "live", 2: "rejected"}
|
||||
assert (await clients.get(f"/catalog/endpoints/{tool_id}")).json()["endpoint"]["version"] == 1
|
||||
# v3 waits again, and a newer waiting version replaces an older one
|
||||
await _v2(clients, tool_id, summary="Decision makers of a company, with verified emails, v3.")
|
||||
await _v2(clients, tool_id, summary="Decision makers of a company, with verified emails, v4.")
|
||||
mine = {t["version"]: t["status"] for t in (await clients.get("/hub/tools/mine")).json() if t["tool_id"] == tool_id}
|
||||
assert mine == {1: "live", 2: "rejected", 3: "superseded", 4: "review"}
|
||||
|
||||
|
||||
async def test_a_price_change_on_a_listed_tool_waits_for_review(clients: AsyncClient, hub_on, platform_on, monkeypatch):
|
||||
pub = await _live_tool_with_readme(clients, monkeypatch, price=0.01)
|
||||
tool_id = pub["tool_id"]
|
||||
await clients.patch(f"/hub/tools/{tool_id}", json={"listed": True})
|
||||
await _decide(clients, monkeypatch, tool_id, "approve")
|
||||
r = await clients.patch(f"/hub/tools/{tool_id}", json={"price_usd": 0.5})
|
||||
assert r.json()["price_label"] == "$0.01 a run" and r.json()["pending_price_usd"] == 0.5
|
||||
assert (await clients.patch(f"/hub/tools/{tool_id}", json={"price_usd": 500})).status_code == 422 # checked now
|
||||
stranger = {"X-Treg-Token": (await funded_user(clients, "price-stranger@example.com"))["token"]}
|
||||
run = await clients.post(f"/call/{tool_id}", json={"domain": "x"}, headers=stranger)
|
||||
assert run.json()["usage"]["price_micro"] == 10_000 # the approved price
|
||||
queue = (await clients.get("/admin/hub/updates", headers={"X-Treg-Token": ADMIN})).json()
|
||||
assert queue[0]["new_price_usd"] == 0.5 and queue[0]["new"] is None
|
||||
await _decide_update(clients, monkeypatch, tool_id, "approve")
|
||||
run = await clients.post(f"/call/{tool_id}", json={"domain": "x"}, headers=stranger)
|
||||
assert run.json()["usage"]["price_micro"] == 500_000
|
||||
|
||||
|
||||
async def test_leaving_search_releases_what_waits(clients: AsyncClient, hub_on, platform_on, monkeypatch):
|
||||
"""An unlisted tool is self-serve: unlisting (or a rejected listing) lets the waiting version
|
||||
and price through, as they would have gone for a tool nobody can find."""
|
||||
pub = await _live_tool_with_readme(clients, monkeypatch, price=0.01)
|
||||
tool_id = pub["tool_id"]
|
||||
await clients.patch(f"/hub/tools/{tool_id}", json={"listed": True})
|
||||
await _decide(clients, monkeypatch, tool_id, "approve")
|
||||
await _v2(clients, tool_id)
|
||||
await clients.patch(f"/hub/tools/{tool_id}", json={"price_usd": 0.02})
|
||||
await clients.patch(f"/hub/tools/{tool_id}", json={"listed": False})
|
||||
got = (await clients.get(f"/catalog/endpoints/{tool_id}")).json()["endpoint"]
|
||||
assert got["version"] == 2 and got["price_line"].startswith("seller $0.02 a run")
|
||||
# an unlisted tool's next version goes live at once
|
||||
assert (await _v2(clients, tool_id, summary="Decision makers of a company, with verified emails, v3.")).json()["status"] == "live"
|
||||
|
||||
|
||||
async def test_the_review_queue_is_superadmin_only(clients: AsyncClient, hub_on, platform_on, monkeypatch):
|
||||
pub = await _live_tool_with_readme(clients, monkeypatch, price=0.01)
|
||||
await clients.patch(f"/hub/tools/{pub['tool_id']}", json={"listed": True})
|
||||
|
||||
Reference in New Issue
Block a user