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:
UncleCode
2026-09-24 17:45:07 +08:00
parent 744200d44f
commit b36832a403
23 changed files with 532 additions and 26 deletions
+5 -2
View File
@@ -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.
+12 -1
View File
@@ -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
+17
View File
@@ -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.
+3 -1
View File
@@ -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
+17
View File
@@ -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>
+8 -2
View File
@@ -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>
+9 -1
View File
@@ -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='';
+1 -1
View File
@@ -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',
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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"])
+118 -2
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+46
View File
@@ -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)}
+4 -2
View File
@@ -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.
+3 -1
View File
@@ -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
+148
View File
@@ -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",
+17
View File
@@ -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
View File
@@ -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})