AI tracker: /trackers/ai with public news, owner-only bots and usage; ai schema (news_items, bot_status, usage_entries); idempotent import scripts; preflight ai grants check
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-25 23:58:39 +00:00
parent 9b21220280
commit cf0fec973c
24 changed files with 38866 additions and 3 deletions

41
scripts/ai-bots-import.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Import Grok Bot agent summaries into ai.bot_status (owner-only /trackers/ai/bots tab).
* Idempotent: upserts on name (focus, recent and last_active are overwritten).
* Short neutral summaries only: never raw transcripts, secrets or private details.
*
* bun scripts/ai-bots-import.ts ai-bots.json # upsert
* bun scripts/ai-bots-import.ts --dry-run ai-bots.json # validate only
*
* JSON: an array of
* { "name": "Daisy", "focus": "…", "recent": "…", "last_active": "2026-10-26T00:45:00+01:00" }
* focus (≤300 chars), recent (≤600) and last_active (ISO date or date-time) may be null.
* Uses DB_URL (the app role on prod); see scripts/ai-news-import.ts for how to run it on stan-ovh.
*/
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { botsFile, upsertBots } from '../src/lib/server/ai-import';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const files = args.filter((a) => !a.startsWith('--'));
if (files.length !== 1) {
console.error('usage: bun scripts/ai-bots-import.ts [--dry-run] <file.json>');
process.exit(2);
}
const parsed = botsFile.safeParse(JSON.parse(await readFile(files[0], 'utf8')));
if (!parsed.success) {
for (const issue of parsed.error.issues.slice(0, 50))
console.error(`${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
console.log(`Valid: ${parsed.data.length} bots`);
if (dryRun) process.exit(0);
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const n = await upsertBots(drizzle({ client }), parsed.data);
console.log(`Upserted ${n} bots`);
} finally {
await client.end();
}

43
scripts/ai-news-import.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Import AI/software digest items into ai.news_items (public /trackers/ai news tab).
* Idempotent: upserts on url (date, title, note and section are overwritten).
*
* bun scripts/ai-news-import.ts ai-news.json # upsert
* bun scripts/ai-news-import.ts --dry-run ai-news.json # validate only
*
* JSON: an array of
* { "date": "2026-10-26", "title": "…", "url": "https://…", "note": "one line", "section": "ai" }
* section is "ai" or "software" (default "ai"); note may be null or omitted.
* Uses DB_URL (the app role on prod). On stan-ovh, run it inside the app image, e.g.
* sudo docker run --rm --network backend --env-file <app env> -v $PWD/ai-news.json:/tmp/in.json:ro \
* <app image> bun scripts/ai-news-import.ts /tmp/in.json
* or from the repo with DB_URL pointing at 127.0.0.1:5440 (never echo it).
*/
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { newsFile, upsertNews } from '../src/lib/server/ai-import';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const files = args.filter((a) => !a.startsWith('--'));
if (files.length !== 1) {
console.error('usage: bun scripts/ai-news-import.ts [--dry-run] <file.json>');
process.exit(2);
}
const parsed = newsFile.safeParse(JSON.parse(await readFile(files[0], 'utf8')));
if (!parsed.success) {
for (const issue of parsed.error.issues.slice(0, 50))
console.error(`${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
console.log(`Valid: ${parsed.data.length} news items`);
if (dryRun) process.exit(0);
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const n = await upsertNews(drizzle({ client }), parsed.data);
console.log(`Upserted ${n} news items`);
} finally {
await client.end();
}

View File

@@ -187,6 +187,30 @@ try {
`;
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
// read/write, personal_ro none. 3 = news_items, bot_status, usage_entries.
const AI_TABLES = 3;
const [ai] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'ai', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'ai' and c.relkind = 'r'
`;
if (
Number(ai.tables) !== AI_TABLES ||
Number(ai.app_rw) !== AI_TABLES ||
Number(ai.ro_read) !== 0 ||
ai.public_usage
)
throw new Error(`ai schema tables/grants are wrong: ${JSON.stringify(ai)}`);
// Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -196,7 +220,7 @@ try {
if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select