Build Rank feature prioritization tool

This commit is contained in:
OpenClaw Bot
2026-05-21 20:03:56 +02:00
commit dec6a844d7
11 changed files with 1894 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
import 'dotenv/config';
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import crypto from 'node:crypto';
import { Client, TablesDB, ID, Query } from 'node-appwrite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 3045);
const endpoint = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_LOCAL_ENDPOINT || process.env.APPWRITE_SELF_HOSTED_URL;
const projectId = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_LOCAL_PROJECT_ID || process.env.APPWRITE_SELF_HOSTED_PROJECT_ID;
const apiKey = process.env.APPWRITE_API_KEY || process.env.APPWRITE_LOCAL_API_KEY || process.env.APPWRITE_SELF_HOSTED_API_KEY;
const databaseId = process.env.RANK_APPWRITE_DATABASE_ID || process.env.APPWRITE_DATABASE_ID || 'priority_rank';
const ideasTableId = process.env.RANK_IDEAS_TABLE_ID || 'ideas';
const milestonesTableId = process.env.RANK_MILESTONES_TABLE_ID || 'milestones';
const activityTableId = process.env.RANK_ACTIVITY_TABLE_ID || 'activity';
const agentToken = process.env.RANK_AGENT_TOKEN || process.env.PRIORITY_AGENT_TOKEN || '';
const appVersion = process.env.APP_VERSION || 'rank-local';
if (!endpoint || !projectId || !apiKey) {
console.warn('[rank] Missing Appwrite configuration; /api/health will report degraded.');
}
const client = new Client();
if (endpoint) client.setEndpoint(endpoint);
if (projectId) client.setProject(projectId);
if (apiKey) client.setKey(apiKey);
const tables = new TablesDB(client);
const app = express();
app.use(express.json({ limit: '256kb' }));
app.use(express.static(path.join(__dirname, 'public'), {
etag: true,
maxAge: process.env.NODE_ENV === 'production' ? '10m' : 0,
}));
function clampInt(value, fallback, min = 0, max = 10) {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function cleanText(value, max = 1000) {
return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
}
function cleanMultiline(value, max = 6000) {
return String(value ?? '').replace(/\r\n/g, '\n').trim().slice(0, max);
}
function scoreIdea({ impact, effort, confidence, urgency }) {
const i = clampInt(impact, 5);
const e = clampInt(effort, 5, 1, 10);
const c = clampInt(confidence, 5);
const u = clampInt(urgency, 5);
return Number((((i * 2.4) + (c * 1.2) + (u * 1.4)) / Math.max(1, e)).toFixed(2));
}
function publicIdea(row) {
return {
id: row.$id,
createdAt: row.$createdAt,
updatedAt: row.$updatedAt,
title: row.title,
description: row.description || '',
source: row.source || 'human',
sourceName: row.sourceName || '',
status: row.status || 'inbox',
milestoneId: row.milestoneId || 'inbox',
impact: row.impact ?? 5,
effort: row.effort ?? 5,
confidence: row.confidence ?? 5,
urgency: row.urgency ?? 5,
score: row.score ?? 0,
rank: row.rank ?? 0,
labels: parseList(row.labels),
notes: row.notes || '',
archived: Boolean(row.archived),
};
}
function publicMilestone(row) {
return {
id: row.$id,
createdAt: row.$createdAt,
updatedAt: row.$updatedAt,
name: row.name,
description: row.description || '',
horizon: row.horizon || '',
color: row.color || '#8cf7ff',
position: row.position ?? 0,
active: row.active !== false,
};
}
function parseList(value) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(String).slice(0, 12) : [];
} catch {
return String(value).split(',').map(s => s.trim()).filter(Boolean).slice(0, 12);
}
}
function encodeList(value) {
if (Array.isArray(value)) return JSON.stringify(value.map(v => cleanText(v, 32)).filter(Boolean).slice(0, 12));
return JSON.stringify(parseList(value));
}
function rowsFrom(result) {
const rows = result?.rows || result?.documents;
if (!Array.isArray(rows)) throw new Error('Appwrite returned an invalid table response');
return rows;
}
function assertRow(row) {
if (!row?.$id) throw new Error('Appwrite returned an invalid row response');
return row;
}
function requireAgent(req, res, next) {
if (!agentToken) return next();
const header = req.get('authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : req.get('x-rank-token');
const tokenBuffer = Buffer.from(token || '');
const expectedBuffer = Buffer.from(agentToken);
if (tokenBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) return next();
return res.status(401).json({ error: 'agent token required' });
}
async function logActivity(type, message, ideaId = '', meta = '') {
try {
await tables.createRow({
databaseId,
tableId: activityTableId,
rowId: ID.unique(),
data: { type, message: cleanText(message, 300), ideaId, meta: cleanText(meta, 800) },
});
} catch (error) {
console.warn('[rank] activity log failed', error.message);
}
}
app.get('/api/health', async (_req, res) => {
const health = { ok: false, app: 'rank', version: appVersion, appwriteConfigured: Boolean(endpoint && projectId && apiKey), appwriteReachable: false, tableReachable: false };
try {
if (health.appwriteConfigured) {
const probe = await tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.limit(1)] });
rowsFrom(probe);
health.appwriteReachable = true;
health.tableReachable = true;
health.ok = true;
}
} catch (error) {
health.error = error.message;
}
res.status(health.ok ? 200 : 503).json(health);
});
app.get('/api/bootstrap', async (_req, res) => {
const [ideas, milestones, activity] = await Promise.all([
tables.listRows({ databaseId, tableId: ideasTableId, queries: [Query.equal('archived', false), Query.orderDesc('score'), Query.orderAsc('rank'), Query.limit(100)] }),
tables.listRows({ databaseId, tableId: milestonesTableId, queries: [Query.equal('active', true), Query.orderAsc('position'), Query.limit(50)] }),
tables.listRows({ databaseId, tableId: activityTableId, queries: [Query.orderDesc('$createdAt'), Query.limit(18)] }).catch(() => ({ rows: [], documents: [] })),
]);
res.json({
version: appVersion,
ideas: rowsFrom(ideas).map(publicIdea),
milestones: rowsFrom(milestones).map(publicMilestone),
activity: rowsFrom(activity).map(row => ({ id: row.$id, createdAt: row.$createdAt, type: row.type, message: row.message, ideaId: row.ideaId || '' })),
scoring: '((impact×2.4)+(confidence×1.2)+(urgency×1.4))/effort',
});
});
app.post('/api/ideas', requireAgent, async (req, res) => {
const title = cleanText(req.body.title, 180);
if (!title) return res.status(400).json({ error: 'title is required' });
const data = {
title,
description: cleanMultiline(req.body.description, 5000),
source: cleanText(req.body.source || 'human', 40),
sourceName: cleanText(req.body.sourceName || req.body.agent || '', 80),
status: cleanText(req.body.status || 'inbox', 40),
milestoneId: cleanText(req.body.milestoneId || 'inbox', 64),
impact: clampInt(req.body.impact, 5),
effort: clampInt(req.body.effort, 5, 1, 10),
confidence: clampInt(req.body.confidence, 6),
urgency: clampInt(req.body.urgency, 5),
rank: clampInt(req.body.rank, 0, -100000, 100000),
labels: encodeList(req.body.labels),
notes: cleanMultiline(req.body.notes, 4000),
archived: false,
};
data.score = scoreIdea(data);
const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data }));
await logActivity('idea.created', `Captured “${title}`, row.$id, data.source);
res.status(201).json(publicIdea(row));
});
app.patch('/api/ideas/:id', requireAgent, async (req, res) => {
const allowed = ['title', 'description', 'source', 'sourceName', 'status', 'milestoneId', 'impact', 'effort', 'confidence', 'urgency', 'rank', 'labels', 'notes', 'archived'];
const data = {};
for (const key of allowed) {
if (!(key in req.body)) continue;
if (['impact', 'effort', 'confidence', 'urgency', 'rank'].includes(key)) data[key] = clampInt(req.body[key], key === 'effort' ? 5 : 0, key === 'effort' ? 1 : -100000, key === 'rank' ? 100000 : 10);
else if (key === 'description' || key === 'notes') data[key] = cleanMultiline(req.body[key], key === 'description' ? 5000 : 4000);
else if (key === 'labels') data[key] = encodeList(req.body[key]);
else if (key === 'archived') data[key] = Boolean(req.body[key]);
else data[key] = cleanText(req.body[key], key === 'title' ? 180 : 80);
}
if (['impact', 'effort', 'confidence', 'urgency'].some(k => k in data)) {
const current = assertRow(await tables.getRow({ databaseId, tableId: ideasTableId, rowId: req.params.id }));
data.score = scoreIdea({ ...current, ...data });
}
const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: req.params.id, data }));
await logActivity('idea.updated', `Updated “${row.title}`, row.$id, Object.keys(data).join(','));
res.json(publicIdea(row));
});
app.post('/api/milestones', requireAgent, async (req, res) => {
const name = cleanText(req.body.name, 80);
if (!name) return res.status(400).json({ error: 'name is required' });
const data = {
name,
description: cleanMultiline(req.body.description, 1000),
horizon: cleanText(req.body.horizon || '', 80),
color: cleanText(req.body.color || '#8cf7ff', 24),
position: clampInt(req.body.position, 0, -10000, 10000),
active: req.body.active !== false,
};
const row = assertRow(await tables.createRow({ databaseId, tableId: milestonesTableId, rowId: ID.unique(), data }));
await logActivity('milestone.created', `Added milestone “${name}`, '', row.$id);
res.status(201).json(publicMilestone(row));
});
app.patch('/api/milestones/:id', requireAgent, async (req, res) => {
const data = {};
for (const key of ['name', 'description', 'horizon', 'color', 'position', 'active']) {
if (!(key in req.body)) continue;
if (key === 'position') data[key] = clampInt(req.body[key], 0, -10000, 10000);
else if (key === 'active') data[key] = Boolean(req.body[key]);
else if (key === 'description') data[key] = cleanMultiline(req.body[key], 1000);
else data[key] = cleanText(req.body[key], key === 'name' ? 80 : 120);
}
const row = assertRow(await tables.updateRow({ databaseId, tableId: milestonesTableId, rowId: req.params.id, data }));
await logActivity('milestone.updated', `Updated milestone “${row.name}`, '', row.$id);
res.json(publicMilestone(row));
});
app.post('/api/reorder', requireAgent, async (req, res) => {
const updates = Array.isArray(req.body.updates) ? req.body.updates.slice(0, 100) : [];
const changed = [];
for (const item of updates) {
if (!item?.id) continue;
const data = {};
if ('rank' in item) data.rank = clampInt(item.rank, 0, -100000, 100000);
if ('milestoneId' in item) data.milestoneId = cleanText(item.milestoneId, 64);
if ('status' in item) data.status = cleanText(item.status, 40);
if (Object.keys(data).length) {
const row = assertRow(await tables.updateRow({ databaseId, tableId: ideasTableId, rowId: item.id, data }));
changed.push(publicIdea(row));
}
}
if (changed.length) await logActivity('ideas.reordered', `Re-ranked ${changed.length} item${changed.length === 1 ? '' : 's'}`);
res.json({ changed });
});
app.get(/.*/, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
app.use((error, _req, res, _next) => {
console.error('[rank]', error);
res.status(error.code && error.code >= 400 && error.code < 600 ? error.code : 500).json({ error: error.message || 'Internal error' });
});
app.listen(PORT, () => console.log(`[rank] ${appVersion} listening on :${PORT}`));