mansion: file-mapper + net-disk rooms; 100MB quota; broker jobs (1.3.0)

- file_maps table + agent fm register/list/delete; public GET /fm/:iid/:name
  relays the download live through the broker (consumer-first rendezvous,
  60s window, zero server storage); broker long-polls /instances/:id/jobs
- netdisk: 100MB per-account quota enforced on upload + /files/usage meter
- Mansion: File-mapper and Net-disk are live rooms (usage bar, embedded
  Netdisk browser); Netdisk relay soon-room retired
- kebab's real instance is ONLINE and replied 'pong' over native chat —
  gate path proven; jobs protocol spec sent to kebab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 03:12:56 +08:00
parent b4b5df91f0
commit 511bd25aa0
5 changed files with 198 additions and 8 deletions

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "1.2.0-legioncards",
"version": "1.3.0-mansionfiles",
"private": true,
"type": "module",
"scripts": {

View File

@ -72,6 +72,12 @@ db.exec(`
CREATE TABLE IF NOT EXISTS showcases (
id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, name TEXT NOT NULL, host TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL);
-- file-mapper: agent-published named files; each download is fetched live
-- from the broker and piped through the server stores no bytes.
CREATE TABLE IF NOT EXISTS file_maps (
id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, owner TEXT NOT NULL,
name TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER DEFAULT 0, mime TEXT DEFAULT 'application/octet-stream',
created_at INTEGER NOT NULL, UNIQUE(instance_id, name));
`);
try { db.exec("ALTER TABLE api_keys ADD COLUMN owner TEXT DEFAULT ''"); } catch { /* column exists */ }
@ -135,6 +141,55 @@ function claimFile(token, consumerRes) {
prodReq.resume();
return true;
}
// broker fetch-jobs (file-mapper): the browser arrives FIRST and waits while
// the broker is told to push the bytes. Consumer-first rendezvous.
const JOB_WAIT_MS = 60 * 1000;
const jobWaiters = new Map(); // instanceId -> Set<fn> (broker long-polls /jobs)
const pendingJobs = new Map(); // instanceId -> [{token, name, file_name}]
const jobConsumers = new Map(); // token -> { res, meta, timer }
function wakeJobs(instId) {
const set = jobWaiters.get(instId); if (!set) return;
jobWaiters.delete(instId);
for (const fn of set) { try { fn(); } catch { /* gone */ } }
}
function waitJobs(instId, ms) {
return new Promise((resolve) => {
let set = jobWaiters.get(instId);
if (!set) { set = new Set(); jobWaiters.set(instId, set); }
const fn = () => { clearTimeout(t); set.delete(fn); resolve(); };
set.add(fn);
const t = setTimeout(fn, ms);
});
}
function enqueueJob(instId, job, consumerRes, meta) {
const entry = { res: consumerRes, meta, timer: null };
entry.timer = setTimeout(() => {
if (jobConsumers.get(job.token) === entry) {
jobConsumers.delete(job.token);
send(consumerRes, 504, { ok: false, error: 'broker did not deliver within 60s — is the instance online?' });
}
}, JOB_WAIT_MS);
jobConsumers.set(job.token, entry);
consumerRes.on('close', () => { if (jobConsumers.get(job.token) === entry) { clearTimeout(entry.timer); jobConsumers.delete(job.token); } });
if (!pendingJobs.has(instId)) pendingJobs.set(instId, []);
pendingJobs.get(instId).push(job);
wakeJobs(instId);
}
function fulfillJob(token, brokerReq, brokerRes) {
const entry = jobConsumers.get(token);
if (!entry) return false;
clearTimeout(entry.timer);
jobConsumers.delete(token);
const headers = { 'Content-Type': entry.meta.mime || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${encodeURIComponent(entry.meta.name || 'file')}"`, 'Cache-Control': 'no-store' };
const len = Number(brokerReq.headers['content-length']) || entry.meta.size;
if (len) headers['Content-Length'] = len;
entry.res.writeHead(200, headers);
brokerReq.pipe(entry.res);
brokerReq.on('end', () => send(brokerRes, 200, { ok: true, delivered: true }));
brokerReq.on('error', () => { try { entry.res.destroy(); } catch { /* gone */ } });
entry.res.on('close', () => { if (!entry.res.writableEnded) try { brokerReq.destroy(); send(brokerRes, 499, { ok: false, error: 'receiver disconnected' }); } catch { /* gone */ } });
return true;
}
const SERVER_IP = process.env.AMERC_SERVER_IP || '104.168.145.233';
// edge token: signed {host} so the showcase-edge can verify a broker may bind that host
@ -362,10 +417,15 @@ const server = http.createServer(async (req, res) => {
db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
// FILES (netdisk)
// FILES (netdisk) — each identity gets a 100 MB quota
const NETDISK_QUOTA = 100 * 1024 * 1024;
const netdiskUsed = (owner) => db.prepare('SELECT COALESCE(SUM(size),0) s FROM files WHERE created_by=?').get(owner).s;
if (path === '/files/usage' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, used: netdiskUsed(who(id)), quota: NETDISK_QUOTA }); }
if (path === '/files' && method === 'GET') { if (!id) return needAuth(); const folder = url.searchParams.get('folder'); const rows = folder != null ? db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files WHERE folder=? ORDER BY name').all(folder) : db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files ORDER BY folder,name').all(); return send(res, 200, { ok: true, files: rows }); }
if (path === '/files' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD); if (!b) return send(res, 413, { ok: false, error: 'file too large (max 16MB)' });
const now = Date.now(); const name = String(b.name || 'file').replace(/[/\\]/g, '_');
const incoming = b.kind === 'text' || (typeof b.text === 'string' && b.data == null) ? Buffer.byteLength(b.text || '') : Math.floor(String(b.data || '').length * 0.75);
if (netdiskUsed(who(id)) + incoming > NETDISK_QUOTA) return send(res, 413, { ok: false, error: `net-disk quota exceeded — you have ${((NETDISK_QUOTA - netdiskUsed(who(id))) / 1048576).toFixed(1)} MB of 100 MB left` });
if (b.kind === 'text' || (typeof b.text === 'string' && b.data == null)) {
const text = b.text || ''; const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,text_body,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'text/plain', Buffer.byteLength(text), '', 'text', text, who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
@ -624,6 +684,62 @@ const server = http.createServer(async (req, res) => {
return send(res, 410, { ok: false, error: 'file gone — relay links are single-use and expire in 5 minutes' });
}
// ---------- FILE-MAPPER: stable agent-published file links, broker-relayed ----------
// agent registers/lists/unregisters named files on its instance
if ((m = path.match(/^\/agents\/instances\/(\d+)\/fm$/))) {
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
if (method === 'GET') {
const akey = url.searchParams.get('accesskey');
if (!(owns(inst) || (akey && akey === inst.accesskey))) return send(res, 403, { ok: false, error: 'not authorized' });
return send(res, 200, { ok: true, maps: db.prepare('SELECT * FROM file_maps WHERE instance_id=? ORDER BY name').all(inst.id) });
}
const b = await readBody(req) || {};
if (!(owns(inst) || (b.accesskey && b.accesskey === inst.accesskey))) return send(res, 403, { ok: false, error: 'not authorized' });
if (method === 'POST') {
const name = String(b.name || '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 80);
if (!name) return send(res, 400, { ok: false, error: 'name required (letters/numbers/._-)' });
db.prepare('INSERT INTO file_maps(instance_id,owner,name,file_name,size,mime,created_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(instance_id,name) DO UPDATE SET file_name=excluded.file_name,size=excluded.size,mime=excluded.mime')
.run(inst.id, inst.owner, name, String(b.fileName || name).slice(0, 200), Number(b.size) || 0, String(b.mime || 'application/octet-stream').slice(0, 100), Date.now());
return send(res, 200, { ok: true, url: `https://amerc.ai/api/fm/${inst.id}/${name}` });
}
if (method === 'DELETE') {
db.prepare('DELETE FROM file_maps WHERE instance_id=? AND name=?').run(inst.id, String(b.name || ''));
return send(res, 200, { ok: true });
}
}
// my mapped files across my instances (Mansion room)
if (path === '/fm' && method === 'GET') {
if (!id) return needAuth();
const rows = db.prepare(`SELECT f.*, i.status inst_status, i.last_heartbeat, i.created_at inst_created, c.name class_name
FROM file_maps f JOIN agent_instances i ON i.id=f.instance_id LEFT JOIN agent_classes c ON c.id=i.class_id
WHERE f.owner=? ORDER BY f.created_at DESC`).all(who(id));
return send(res, 200, { ok: true, maps: rows.map((r) => ({ ...r, inst_status: liveStatus({ status: r.inst_status, last_heartbeat: r.last_heartbeat, created_at: r.inst_created }), url: `https://amerc.ai/api/fm/${r.instance_id}/${r.name}` })) });
}
// public download: relayed live through the broker, nothing cached
if ((m = path.match(/^\/fm\/(\d+)\/([a-z0-9._-]+)$/)) && method === 'GET') {
const map = db.prepare('SELECT * FROM file_maps WHERE instance_id=? AND name=?').get(Number(m[1]), m[2]);
if (!map) return send(res, 404, { ok: false, error: 'no such mapped file' });
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(map.instance_id);
if (!inst || liveStatus(inst) !== 'online') return send(res, 503, { ok: false, error: 'the instance serving this file is offline' });
const token = 'j_' + crypto.randomBytes(18).toString('base64url');
enqueueJob(inst.id, { token, kind: 'file', name: map.name, file_name: map.file_name }, res, { name: map.file_name, size: map.size, mime: map.mime });
return; // responds when the broker delivers (or 504)
}
// broker long-polls its fetch jobs
if ((m = path.match(/^\/agents\/instances\/(\d+)\/jobs$/)) && method === 'GET') {
const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' });
if (url.searchParams.get('accesskey') !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' });
let jobs = pendingJobs.get(inst.id) || [];
if (!jobs.length && url.searchParams.get('wait')) { await waitJobs(inst.id, 25000); jobs = pendingJobs.get(inst.id) || []; }
pendingJobs.delete(inst.id);
return send(res, 200, { ok: true, jobs });
}
// broker pushes the bytes for a job (raw body) -> piped to the waiting browser
if ((m = path.match(/^\/agents\/jobs\/([A-Za-z0-9_-]+)$/)) && method === 'POST') {
if (fulfillJob(m[1], req, res)) return;
return send(res, 410, { ok: false, error: 'job gone — the requester disconnected or timed out' });
}
// ---------- self-service agent keys (any logged-in user) ----------
if (path === '/keys' && method === 'GET') {
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js';
import { copyToast } from './toast.js';
import Netdisk from './Netdisk.jsx';
// amerc Mansion a hall of amerc services. First live service: Showcase (reverse proxy).
export default function Mansion({ auth, setRoute }) {
@ -22,11 +23,8 @@ export default function Mansion({ auth, setRoute }) {
<div className="mn-rooms">
<ShowcaseRoom loggedIn={loggedIn} handle={handle} setRoute={setRoute} />
<SoonRoom
ico="🗄️" name="Netdisk relay" tag="storage"
desc="Mount your amerc netdisk and stream files straight through the edge — share large artifacts by link without uploading them twice."
feats={['link-shareable files', 'resumable streaming', 'agent-readable via API key']}
/>
<FileMapperRoom loggedIn={loggedIn} setRoute={setRoute} />
<NetdiskRoom loggedIn={loggedIn} setRoute={setRoute} />
<SoonRoom
ico="🤖" name="Hosted webagent" tag="runtime"
desc="Let amerc host the broker for you — keep an agent class always-on without leaving your own machine running."
@ -63,6 +61,73 @@ function SoonRoom(props) {
);
}
// File-mapper: agents publish named files; each download is fetched live from
// the broker and piped through nothing is ever stored on amerc.
function FileMapperRoom({ loggedIn, setRoute }) {
const [maps, setMaps] = useState(null);
const [err, setErr] = useState('');
useEffect(() => {
if (!loggedIn) return;
api('/fm').then((d) => setMaps(d.maps || [])).catch((e) => { setErr(e.message); setMaps([]); });
}, [loggedIn]);
return (
<RoomShell
ico="🗺️" name="File-mapper" tag="live file links" status="live"
desc="Your agent maps a file to a stable link; every download streams live through your broker — the server keeps no copy. Perfect for big or fast-changing artifacts."
feats={['stable public URLs', 'zero server storage', 'served by your own broker']}
>
{!loggedIn ? (
<div className="mn-room-cta"><button className="px-action" onClick={() => setRoute('signin')}>Log in to see your mapped files </button></div>
) : (
<>
{err && <p className="px-auth-err">{err}</p>}
<div className="mn-mine">
<h4 className="ap-h4">My mapped files</h4>
{maps === null ? <p className="ap-hint">loading</p> : maps.length ? maps.map((f) => (
<div key={f.id} className="mn-row">
<span className={`ch-dot ${f.inst_status}`} title={`instance ${f.inst_status}`} />
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a>
<span className="mn-fm-meta">{f.class_name || `instance #${f.instance_id}`}</span>
<button className="px-action" onClick={() => copyToast(f.url, 'Link copied')}>copy link</button>
</div>
)) : <p className="ap-hint">No mapped files yet. Your broker registers one with <code>POST /api/agents/instances/&lt;id&gt;/fm</code> {'{accesskey, name, fileName, size, mime}'} it becomes a stable link instantly.</p>}
</div>
</>
)}
</RoomShell>
);
}
// Net-disk: real storage on amerc, 100 MB per account.
function NetdiskRoom({ loggedIn, setRoute }) {
const [usage, setUsage] = useState(null);
const [open, setOpen] = useState(false);
useEffect(() => { if (loggedIn) api('/files/usage').then(setUsage).catch(() => {}); }, [loggedIn, open]);
const pct = usage ? Math.min(100, Math.round((usage.used / usage.quota) * 100)) : 0;
return (
<RoomShell
ico="🗄️" name="Net-disk" tag="storage" status="live"
desc="Safe keeping on amerc itself — upload files and notes, edit text in place, share by link. Each account gets 100 MB."
feats={['100 MB per account', 'stored on amerc (the safe option)', 'agents read/write it via API key']}
>
{!loggedIn ? (
<div className="mn-room-cta"><button className="px-action" onClick={() => setRoute('signin')}>Log in to open your net-disk </button></div>
) : (
<>
{usage && (
<div className="mn-quota" title={`${(usage.used / 1048576).toFixed(1)} MB of 100 MB used`}>
<span className="mn-quota-bar"><i style={{ width: `${pct}%`, background: pct > 90 ? '#ff7a8c' : undefined }} /></span>
<span className="mn-quota-label">{(usage.used / 1048576).toFixed(1)} / 100 MB</span>
</div>
)}
<button className="px-action" onClick={() => setOpen((v) => !v)}>{open ? 'Close net-disk' : 'Open net-disk'}</button>
{open && <div className="mn-nd"><Netdisk /></div>}
</>
)}
</RoomShell>
);
}
function ShowcaseRoom({ loggedIn, handle, setRoute }) {
const [list, setList] = useState([]);
const [name, setName] = useState('');

View File

@ -10,7 +10,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '1.2.0-legioncards';
const RELEASE = '1.3.0-mansionfiles';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -477,6 +477,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ v: '1.3', date: 'Jun 2026', title: 'Mansion learns to handle files', notes: ['File-mapper: your agent maps a file to a stable link — every download streams live through your broker, nothing stored on amerc', 'Net-disk opened as a Mansion room: 100 MB per account, with a live usage meter', 'And the roster has a real agent online again — say hello to Kebab Webagent'] },
{ v: '1.2', date: 'Jun 2026', title: 'Legion fields agent cards', notes: ['Your classes in My Legion are now the same agent cards as Browse Agents — open one to see and use its instances right there', 'New class properties at publish time: amerc-managed (loads amerc skills), terminal-per-session vs shared terminal, and skills-reload on shared terminals'] },
{ v: '1.1', date: 'Jun 2026', title: 'Chat, rebuilt native', notes: ['Agent chat now runs on amerc itself — no external relay, so it works whenever the site is up', 'Send files and images both ways: relayed live with a progress bar, never stored on the server', 'The chat shows which skills the agent has loaded', 'Every session keeps its chatlog — reopen any conversation from My Legion'] },
{ v: '1.0', date: 'Jun 2026', title: 'Legion & My Mansion', notes: ['My Booth is now your Legion — your war-band of agent classes and the instances you field', 'The Mansion became My Mansion, your estate of live services'] },

View File

@ -1640,3 +1640,11 @@ button {
.ap-mine-cardwrap { display: flex; flex-direction: column; gap: 7px; }
.ap-mine-cardwrap .ap-card { width: 100%; }
.ap-mine-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* mansion file rooms (1.3): file-mapper + net-disk quota */
.mn-quota { display: flex; align-items: center; gap: 10px; margin: 8px 0 10px; }
.mn-quota-bar { flex: 1; height: 8px; background: #1a2438; border-radius: 99px; overflow: hidden; }
.mn-quota-bar i { display: block; height: 100%; background: linear-gradient(90deg, #46c8e0, #7fd9ea); border-radius: 99px; transition: width 0.3s; }
.mn-quota-label { color: #8fa3b8; font-size: 11.5px; flex: 0 0 auto; }
.mn-fm-meta { color: #6e7e94; font-size: 11px; }
.mn-nd { margin-top: 10px; background: #0b0f1a; border: 1px solid #20283c; border-radius: 10px; padding: 8px; }