trantor 0.17.53 → 0.17.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -0
- package/bin/adopt.mjs +117 -0
- package/bin/cli.mjs +98 -1
- package/bin/crew-runner.mjs +45 -8
- package/bin/crew.sh +27 -0
- package/bin/doctor.mjs +20 -9
- package/bin/inbox.mjs +64 -0
- package/bin/migrate-store.mjs +0 -0
- package/bin/overseer-narrate.mjs +77 -0
- package/bin/policy.mjs +74 -0
- package/bin/summarize.mjs +105 -0
- package/deploy/backup.sh +49 -0
- package/deploy/docker-compose.yml +29 -0
- package/deploy/hub.env +29 -0
- package/deploy/restore.sh +69 -0
- package/deploy/retention.sh +12 -0
- package/deploy/setup-netcup.sh +87 -0
- package/deploy/trantor-hub.service +50 -0
- package/hooks/agent-notify.mjs +3 -14
- package/hooks/file-claim.mjs +91 -0
- package/hooks/heartbeat.mjs +36 -16
- package/hooks/hooks.json +115 -11
- package/hooks/inbox-deliver.mjs +7 -18
- package/hooks/lib/api.mjs +163 -0
- package/hooks/lib/balance-check.mjs +3 -10
- package/hooks/lib/handoff.mjs +7 -8
- package/hooks/overseer-warn.mjs +84 -0
- package/hooks/prompt-focus.mjs +8 -12
- package/hooks/sessionstart.mjs +10 -13
- package/hooks/stop-inbox.mjs +106 -0
- package/hooks/subagent-cost.mjs +8 -16
- package/hooks/subagent-start.mjs +3 -15
- package/hooks/todo-sync.mjs +3 -19
- package/hub.mjs +0 -0
- package/lib/enroll.mjs +65 -0
- package/lib/identity.mjs +153 -0
- package/lib/overseer.mjs +140 -0
- package/lib/project.mjs +55 -0
- package/lib/scrub.mjs +67 -0
- package/lib/signed-fetch.mjs +40 -0
- package/lib/store-contract.mjs +190 -0
- package/lib/store-pg.mjs +682 -0
- package/mcp.mjs +42 -15
- package/package.json +4 -3
- package/ui.html +150 -4
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor overseer-narrate — narrate overseer.warn events with plain-language action text.
|
|
3
|
+
//
|
|
4
|
+
// trantor overseer-narrate [--limit N] [--dry] [--quiet]
|
|
5
|
+
//
|
|
6
|
+
// The overseer tick emits overseer.warn events as mechanical, computed facts (kind, sessions, files,
|
|
7
|
+
// detail). This worker gives each un-narrated warning a one-line action text ("coordinate over the
|
|
8
|
+
// bus / split files / declare a link") written by a CHEAP model, then POSTs it back to the hub.
|
|
9
|
+
// Economics by design: candidates are unnarrated overseer.warn events, batched into ONE cheap-model
|
|
10
|
+
// call per hub, difficulty easy. Runs ambiently from the heartbeat and on demand.
|
|
11
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
16
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
|
|
20
|
+
const has = (k) => argv.includes(`--${k}`);
|
|
21
|
+
const QUIET = has("quiet");
|
|
22
|
+
const say = (...a) => { if (!QUIET) console.log(...a); };
|
|
23
|
+
|
|
24
|
+
const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
25
|
+
let config = {}; try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch {}
|
|
26
|
+
const LIMIT = Math.max(1, Number(val("limit")) || 100);
|
|
27
|
+
const OWNER = config.ownerIdentity || "admin";
|
|
28
|
+
const ownerId = loadOrCreate(OWNER, "human");
|
|
29
|
+
|
|
30
|
+
const scroogeBin = () => process.env.SCROOGE_BIN
|
|
31
|
+
|| (() => { try { return execSync("command -v scrooge", { encoding: "utf8" }).trim(); } catch {} })()
|
|
32
|
+
|| (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
|
|
33
|
+
|
|
34
|
+
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
|
35
|
+
const get = async (hub, path) => (await sfetchJson(`${hub}${path}`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) })).json();
|
|
36
|
+
|
|
37
|
+
let narrated = 0, considered = 0;
|
|
38
|
+
for (const hub of hubs) {
|
|
39
|
+
let events = [];
|
|
40
|
+
try { events = (await get(hub, `/events?type=overseer.&limit=${LIMIT}`)).events ?? []; }
|
|
41
|
+
catch { continue; }
|
|
42
|
+
const unnarrated = events.filter(e => !e.narrated && e.detail);
|
|
43
|
+
considered += unnarrated.length;
|
|
44
|
+
if (!unnarrated.length) continue;
|
|
45
|
+
say(`${hub}: ${unnarrated.length} overseer warning(s) need narration`);
|
|
46
|
+
|
|
47
|
+
const blocks = unnarrated.map(e =>
|
|
48
|
+
`EVENT #${e.id} [${e.project}]\nKIND: ${e.kind}\nSESSIONS: ${(e.sessions || []).join(", ")}\nFILES: ${(e.files || []).join(", ")}\nDETAIL: ${e.detail}`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const prompt = `You write one-line action narratives for AI-agent collision warnings. For EACH event below, write what the sessions should do — coordinate over the bus, split files, declare a link. <=200 chars. Cite sessions/files/projects from the detail. Return ONLY a JSON array: [{"eventId":<number>,"text":"..."}]
|
|
52
|
+
|
|
53
|
+
${blocks.join("\n\n")}`;
|
|
54
|
+
|
|
55
|
+
const bin = scroogeBin();
|
|
56
|
+
if (!bin) { console.error("scrooge not found (set SCROOGE_BIN) — cannot narrate."); process.exit(1); }
|
|
57
|
+
const res = spawnSync(bin, ["-t", "reason", "-d", "easy", "--json"], { input: prompt, encoding: "utf8", timeout: 120000 });
|
|
58
|
+
if (res.error || (res.status !== 0 && !res.stdout)) { console.error(`scrooge failed: ${(res.stderr || res.error?.message || "").slice(-200)}`); continue; }
|
|
59
|
+
let rows = [];
|
|
60
|
+
try { const out = res.stdout || ""; rows = JSON.parse(out.slice(out.indexOf("["), out.lastIndexOf("]") + 1)); }
|
|
61
|
+
catch { console.error(`unparseable narrator output: ${(res.stdout || "").slice(0, 200)}`); continue; }
|
|
62
|
+
|
|
63
|
+
const byId = new Map(unnarrated.map(e => [e.id, e]));
|
|
64
|
+
for (const r of rows) {
|
|
65
|
+
const e = byId.get(Number(r.eventId));
|
|
66
|
+
const text = String(r.text || "").trim().slice(0, 200);
|
|
67
|
+
if (!e || !text) continue;
|
|
68
|
+
if (has("dry")) { say(` event #${e.id} would become: ${text}`); continue; }
|
|
69
|
+
try {
|
|
70
|
+
const res = await sfetchJson(`${hub}/overseer/narrate`, { identity: ownerId, payload: { eventId: e.id, text }, signal: AbortSignal.timeout(8000) });
|
|
71
|
+
if (!res.ok) { say(` event #${e.id} POST failed: ${res.status}`); continue; }
|
|
72
|
+
narrated++;
|
|
73
|
+
say(` event #${e.id} → ${text}`);
|
|
74
|
+
} catch (e2) { say(` event #${e.id} write failed: ${e2.message}`); }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
say(`\n${has("dry") ? "[dry] " : ""}${narrated} narration(s) written · ${considered} event(s) considered`);
|
package/bin/policy.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor policy — the autonomy ladder's admin surface (PRD §6): show levels + links,
|
|
3
|
+
// set a project's level, declare that two projects are codependent.
|
|
4
|
+
// trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>"
|
|
5
|
+
// Drafted by scrooge (deepseek-v4-flash), integrated by the orchestrator.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
10
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
11
|
+
|
|
12
|
+
const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
13
|
+
let config;
|
|
14
|
+
try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch { config = {}; }
|
|
15
|
+
|
|
16
|
+
const OWNER = config.ownerIdentity || "admin";
|
|
17
|
+
const ownerId = loadOrCreate(OWNER, "human");
|
|
18
|
+
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
|
19
|
+
|
|
20
|
+
const get = async (hub) => {
|
|
21
|
+
const res = await sfetchJson(`${hub}/policy`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) });
|
|
22
|
+
return res.json();
|
|
23
|
+
};
|
|
24
|
+
const post = async (hub, payload) => {
|
|
25
|
+
const res = await sfetchJson(`${hub}/policy`, { method: "POST", identity: ownerId, payload, signal: AbortSignal.timeout(8000) });
|
|
26
|
+
const json = await res.json();
|
|
27
|
+
if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
|
|
28
|
+
return json;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const legend = { 1: "1 observe", 2: "2 warn", 3: "3 gate", 4: "4 auto" };
|
|
32
|
+
function usage() {
|
|
33
|
+
console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>"');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [,, cmd, arg1, arg2] = process.argv;
|
|
38
|
+
|
|
39
|
+
if (cmd === "show" || !cmd) {
|
|
40
|
+
for (const hub of hubs) {
|
|
41
|
+
try {
|
|
42
|
+
const data = await get(hub);
|
|
43
|
+
console.log(hub);
|
|
44
|
+
console.log(" autonomy:");
|
|
45
|
+
for (const [proj, level] of Object.entries(data.autonomy || {})) console.log(` ${proj}: ${legend[level] || level}`);
|
|
46
|
+
console.log(" links:");
|
|
47
|
+
for (const l of data.links || []) console.log(` ${l.projects.join(" ↔ ")} — ${l.reason} (by ${l.declaredBy})`);
|
|
48
|
+
if (!(data.links || []).length) console.log(" (none)");
|
|
49
|
+
} catch (err) { console.warn(` ⚠ ${hub}: ${err.message}`); }
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (cmd === "set") {
|
|
55
|
+
if (!arg1 || !["1", "2", "3", "4"].includes(arg2)) usage();
|
|
56
|
+
for (const hub of hubs) {
|
|
57
|
+
try { await post(hub, { autonomy: { [arg1]: Number(arg2) } }); console.log(`✓ ${arg1} → ${legend[Number(arg2)]} on ${hub}`); }
|
|
58
|
+
catch (err) { console.warn(`⚠ ${hub}: ${err.message}`); }
|
|
59
|
+
}
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cmd === "link") {
|
|
64
|
+
const reasonIdx = process.argv.indexOf("--reason");
|
|
65
|
+
const reason = reasonIdx >= 0 ? process.argv[reasonIdx + 1] : null;
|
|
66
|
+
if (!arg1 || !arg2 || !reason) usage();
|
|
67
|
+
for (const hub of hubs) {
|
|
68
|
+
try { await post(hub, { link: { projects: [arg1, arg2], reason } }); console.log(`✓ ${arg1} ↔ ${arg2} on ${hub}`); }
|
|
69
|
+
catch (err) { console.warn(`⚠ ${hub}: ${err.message}`); }
|
|
70
|
+
}
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
usage();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor summarize — narrative cards: what was ASSIGNED and what was DONE, in plain language.
|
|
3
|
+
//
|
|
4
|
+
// trantor summarize [--project <p>] [--limit N] [--all] [--dry] [--quiet]
|
|
5
|
+
//
|
|
6
|
+
// Title-cleaning makes machine-generated cards readable; it cannot make them MEAN anything. This
|
|
7
|
+
// gives each card a one-line narrative ("assigned — did") written by a CHEAP model from the card's
|
|
8
|
+
// own thread (its events + the messages that cite it). The board then reads as a story a human can
|
|
9
|
+
// follow, which was the ask: "what the agent was assigned, what the agent did, what the flow was."
|
|
10
|
+
//
|
|
11
|
+
// Economics by design (the Scrooge doctrine): candidates are MACHINE-TITLED cards without a
|
|
12
|
+
// summary (--all widens to every unsummarized card), batched into ONE cheap-model call per hub,
|
|
13
|
+
// difficulty easy, capped per run. The summary lands via /task/update and rides the tasks.extra
|
|
14
|
+
// column — permanent, never recomputed. Runs ambiently from the heartbeat (hourly, detached) and
|
|
15
|
+
// on demand as `trantor summarize`.
|
|
16
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
17
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { loadOrCreate } from "../lib/identity.mjs";
|
|
21
|
+
import { sfetchJson } from "../lib/signed-fetch.mjs";
|
|
22
|
+
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
|
|
25
|
+
const has = (k) => argv.includes(`--${k}`);
|
|
26
|
+
const QUIET = has("quiet");
|
|
27
|
+
const say = (...a) => { if (!QUIET) console.log(...a); };
|
|
28
|
+
|
|
29
|
+
const BUS_DIR = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
30
|
+
let config = {}; try { config = JSON.parse(readFileSync(join(BUS_DIR, "config.json"), "utf8")); } catch {}
|
|
31
|
+
const LIMIT = Math.max(1, Number(val("limit")) || 25);
|
|
32
|
+
const PROJECT = val("project");
|
|
33
|
+
const OWNER = config.ownerIdentity || "admin";
|
|
34
|
+
const ownerId = loadOrCreate(OWNER, "human");
|
|
35
|
+
|
|
36
|
+
const scroogeBin = () => process.env.SCROOGE_BIN
|
|
37
|
+
|| (() => { try { return execSync("command -v scrooge", { encoding: "utf8" }).trim(); } catch {} })()
|
|
38
|
+
|| (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
|
|
39
|
+
|
|
40
|
+
// A title no human wrote: protocol frames, dumped prompts, id soup. These are the cards whose
|
|
41
|
+
// board presence is noise until a narrative replaces them.
|
|
42
|
+
const machineTitled = (t) =>
|
|
43
|
+
/^\s*[<{[]/.test(t) || /<task-notification>|<system-reminder>|toolu_[A-Za-z0-9]/.test(t) ||
|
|
44
|
+
/^\s*(subagent|general-purpose|Explore|Task|Plan):/i.test(t) || t.length > 130;
|
|
45
|
+
|
|
46
|
+
const hubs = new Set([config.url || "http://127.0.0.1:4477", ...Object.values(config.hubs || {})]);
|
|
47
|
+
const get = async (hub, path) => (await sfetchJson(`${hub}${path}`, { method: "GET", identity: ownerId, signal: AbortSignal.timeout(8000) })).json();
|
|
48
|
+
const post = async (hub, path, payload) => {
|
|
49
|
+
const r = await sfetchJson(`${hub}${path}`, { identity: ownerId, payload, signal: AbortSignal.timeout(8000) });
|
|
50
|
+
const j = await r.json();
|
|
51
|
+
if (!r.ok || j?.error) throw new Error(`${path} → ${r.status} ${j?.error || ""}`);
|
|
52
|
+
return j;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let wrote = 0, considered = 0;
|
|
56
|
+
for (const hub of hubs) {
|
|
57
|
+
let cards = [];
|
|
58
|
+
try { cards = (await get(hub, PROJECT ? `/tasks?project=${encodeURIComponent(PROJECT)}` : "/tasks")).tasks ?? []; }
|
|
59
|
+
catch { continue; }
|
|
60
|
+
const cand = cards
|
|
61
|
+
.filter(t => !t.summary && (has("all") || machineTitled(String(t.title || ""))))
|
|
62
|
+
.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))
|
|
63
|
+
.slice(0, LIMIT);
|
|
64
|
+
considered += cand.length;
|
|
65
|
+
if (!cand.length) continue;
|
|
66
|
+
say(`${hub}: ${cand.length} card(s) need a narrative`);
|
|
67
|
+
|
|
68
|
+
// each card's own story: status + thread tail (the agent's reports of what it did)
|
|
69
|
+
const blocks = [];
|
|
70
|
+
for (const t of cand) {
|
|
71
|
+
let thread = "";
|
|
72
|
+
try {
|
|
73
|
+
const c = await get(hub, `/card?id=${t.id}`);
|
|
74
|
+
thread = (c.messages ?? []).slice(-5).map(m => `${m.from}: ${String(m.text).replace(/\s+/g, " ").slice(0, 220)}`).join("\n");
|
|
75
|
+
} catch {}
|
|
76
|
+
blocks.push(`CARD #${t.id} [${t.status}]${t.assignee ? ` assignee=${t.assignee}` : ""}\nTITLE: ${String(t.title).replace(/\s+/g, " ").slice(0, 400)}${thread ? `\nTHREAD:\n${thread}` : ""}`);
|
|
77
|
+
}
|
|
78
|
+
const prompt = `You write one-line narratives for cards on an AI-agent Kanban board. For EACH card below:
|
|
79
|
+
- "assigned": the task in plain language, action-first, <=70 chars (from TITLE; ignore XML/ids/protocol noise)
|
|
80
|
+
- "did": what actually happened per THREAD and status, <=80 chars; "" if nothing has happened yet
|
|
81
|
+
Plain words a human skims. No ids, no paths unless essential, no XML.
|
|
82
|
+
Return ONLY a JSON array: [{"id":<number>,"assigned":"...","did":"..."}]
|
|
83
|
+
|
|
84
|
+
${blocks.join("\n\n")}`;
|
|
85
|
+
|
|
86
|
+
const bin = scroogeBin();
|
|
87
|
+
if (!bin) { console.error("scrooge not found (set SCROOGE_BIN) — cannot summarize."); process.exit(1); }
|
|
88
|
+
const res = spawnSync(bin, ["-t", "summarize", "-d", "easy", "--json"], { input: prompt, encoding: "utf8", timeout: 120000 });
|
|
89
|
+
if (res.error || (res.status !== 0 && !res.stdout)) { console.error(`scrooge failed: ${(res.stderr || res.error?.message || "").slice(-200)}`); continue; }
|
|
90
|
+
let rows = [];
|
|
91
|
+
try { const out = res.stdout || ""; rows = JSON.parse(out.slice(out.indexOf("["), out.lastIndexOf("]") + 1)); }
|
|
92
|
+
catch { console.error(`unparseable summarizer output: ${(res.stdout || "").slice(0, 200)}`); continue; }
|
|
93
|
+
|
|
94
|
+
const byId = new Map(cand.map(t => [t.id, t]));
|
|
95
|
+
for (const r of rows) {
|
|
96
|
+
const t = byId.get(Number(r.id));
|
|
97
|
+
const assigned = String(r.assigned || "").trim();
|
|
98
|
+
if (!t || !assigned) continue;
|
|
99
|
+
const summary = (r.did ? `${assigned} — ${String(r.did).trim()}` : assigned).slice(0, 220);
|
|
100
|
+
if (has("dry")) { say(` #${t.id} would become: ${summary}`); continue; }
|
|
101
|
+
try { await post(hub, "/task/update", { id: t.id, summary, by: "scrooge-summarizer" }); wrote++; say(` #${t.id} → ${summary}`); }
|
|
102
|
+
catch (e) { say(` #${t.id} write failed: ${e.message}`); }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
say(`\n${has("dry") ? "[dry] " : ""}${wrote} narrative(s) written · ${considered} candidate(s) seen`);
|
package/deploy/backup.sh
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# trantor — backup Postgres + hub data to a timestamped tarball.
|
|
3
|
+
# Safe to run while the hub is running (pg_dump is consistent per snapshot).
|
|
4
|
+
# Retention: keeps last $KEEP_DAYS days (default 30).
|
|
5
|
+
# Usage: ./deploy/backup.sh [--pg-password PASSWORD]
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
BACKUP_DIR="${BACKUP_DIR:-/var/lib/trantor/backups}"
|
|
9
|
+
KEEP_DAYS="${KEEP_DAYS:-30}"
|
|
10
|
+
PG_USER="${PG_USER:-trantor}"
|
|
11
|
+
PG_HOST="${PG_HOST:-127.0.0.1}"
|
|
12
|
+
PG_DB="${PG_DB:-trantor}"
|
|
13
|
+
DATA_DIR="${DATA_DIR:-/var/lib/trantor/data}"
|
|
14
|
+
|
|
15
|
+
PG_PASSWORD=""
|
|
16
|
+
while [ $# -gt 0 ]; do
|
|
17
|
+
case "$1" in
|
|
18
|
+
--pg-password) PG_PASSWORD="$2"; shift 2 ;;
|
|
19
|
+
-p) PG_PASSWORD="$2"; shift 2 ;;
|
|
20
|
+
*) echo "unknown flag: $1"; exit 1 ;;
|
|
21
|
+
esac
|
|
22
|
+
done
|
|
23
|
+
|
|
24
|
+
mkdir -p "$BACKUP_DIR"
|
|
25
|
+
TIMESTAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
|
26
|
+
BACKUP_FILE="$BACKUP_DIR/trantor-$TIMESTAMP.tar.gz"
|
|
27
|
+
|
|
28
|
+
TMP="$(mktemp -d)"
|
|
29
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
30
|
+
|
|
31
|
+
# Dump Postgres
|
|
32
|
+
if [ -n "$PG_PASSWORD" ]; then
|
|
33
|
+
PGPASSWORD="$PG_PASSWORD" pg_dump -U "$PG_USER" -h "$PG_HOST" -d "$PG_DB" -Fc -f "$TMP/pg.dump"
|
|
34
|
+
else
|
|
35
|
+
pg_dump -U "$PG_USER" -h "$PG_HOST" -d "$PG_DB" -Fc -f "$TMP/pg.dump"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Copy hub data (bus.json, identities, etc.)
|
|
39
|
+
if [ -d "$DATA_DIR" ]; then
|
|
40
|
+
cp -r "$DATA_DIR" "$TMP/hub-data"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
tar -czf "$BACKUP_FILE" -C "$TMP" .
|
|
44
|
+
|
|
45
|
+
# Prune backups older than KEEP_DAYS
|
|
46
|
+
find "$BACKUP_DIR" -name "trantor-*.tar.gz" -mtime "+$KEEP_DAYS" -delete
|
|
47
|
+
|
|
48
|
+
echo "backup: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
|
49
|
+
echo "retention: keeping last $KEEP_DAYS days"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# trantor netcup deploy — Dockerised Postgres for the relay hub.
|
|
2
|
+
# Start: docker compose -f deploy/docker-compose.yml up -d
|
|
3
|
+
# Stop: docker compose -f deploy/docker-compose.yml down
|
|
4
|
+
services:
|
|
5
|
+
postgres:
|
|
6
|
+
image: postgres:16-alpine
|
|
7
|
+
container_name: trantor-pg
|
|
8
|
+
restart: unless-stopped
|
|
9
|
+
environment:
|
|
10
|
+
POSTGRES_USER: trantor
|
|
11
|
+
POSTGRES_PASSWORD: ${PG_PASSWORD:?set PG_PASSWORD in .env}
|
|
12
|
+
POSTGRES_DB: trantor
|
|
13
|
+
volumes:
|
|
14
|
+
- pgdata:/var/lib/postgresql/data
|
|
15
|
+
ports:
|
|
16
|
+
- "127.0.0.1:5432:5432"
|
|
17
|
+
healthcheck:
|
|
18
|
+
test: ["CMD-SHELL", "pg_isready -U trantor -d trantor"]
|
|
19
|
+
interval: 10s
|
|
20
|
+
timeout: 5s
|
|
21
|
+
retries: 5
|
|
22
|
+
logging:
|
|
23
|
+
driver: "json-file"
|
|
24
|
+
options:
|
|
25
|
+
max-size: "10m"
|
|
26
|
+
max-file: "3"
|
|
27
|
+
|
|
28
|
+
volumes:
|
|
29
|
+
pgdata:
|
package/deploy/hub.env
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# trantor hub env — deployed on netcup (Debian 13)
|
|
2
|
+
# Bind to the tailnet IP so agents on other machines can reach the hub.
|
|
3
|
+
# Non-loopback bind FORCES RELAY_AUTH=enforce (hub.mjs will exit otherwise).
|
|
4
|
+
RELAY_HOST=100.79.242.104
|
|
5
|
+
RELAY_PORT=4477
|
|
6
|
+
RELAY_AUTH=enforce
|
|
7
|
+
RELAY_ENROLL=invite
|
|
8
|
+
|
|
9
|
+
# Persistent data directory (separate volume, survives restarts)
|
|
10
|
+
RELAY_DATA_DIR=/var/lib/trantor/data
|
|
11
|
+
|
|
12
|
+
# TIME-based retention — events older than this (ms) are pruned.
|
|
13
|
+
# 30 days = 30 × 86400 × 1000
|
|
14
|
+
RELAY_EVENT_RETENTION_MS=2592000000
|
|
15
|
+
|
|
16
|
+
# Unbounded-log guard — cap total events regardless of age
|
|
17
|
+
RELAY_EVENT_CAP=50000
|
|
18
|
+
|
|
19
|
+
# Peer TTL: how long after last heartbeat before a peer is pruned (24h)
|
|
20
|
+
RELAY_PEER_TTL_MS=86400000
|
|
21
|
+
|
|
22
|
+
# Online window: a peer is "online" if last seen within this (5 min)
|
|
23
|
+
RELAY_ONLINE_MS=300000
|
|
24
|
+
|
|
25
|
+
# Stale-card reaper grace: card owner offline for 15 min → stale
|
|
26
|
+
RELAY_REAP_GRACE_MS=900000
|
|
27
|
+
|
|
28
|
+
# Postgres connection (set in /etc/trantor/env, never committed)
|
|
29
|
+
# DATABASE_URL=postgresql://trantor:<password>@127.0.0.1:5432/trantor
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# trantor — restore a backup created by backup.sh.
|
|
3
|
+
# WARNING: this drops and recreates the Postgres database.
|
|
4
|
+
# Usage: ./deploy/restore.sh <backup.tar.gz> [--pg-password PASSWORD]
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
if [ $# -lt 1 ]; then
|
|
8
|
+
echo "usage: $0 <backup.tar.gz> [--pg-password PASS]"
|
|
9
|
+
echo "backups available:"
|
|
10
|
+
ls -1 /var/lib/trantor/backups/ 2>/dev/null || echo " (none)"
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
BACKUP_FILE="$1"; shift
|
|
15
|
+
PG_USER="${PG_USER:-trantor}"
|
|
16
|
+
PG_HOST="${PG_HOST:-127.0.0.1}"
|
|
17
|
+
PG_DB="${PG_DB:-trantor}"
|
|
18
|
+
DATA_DIR="${DATA_DIR:-/var/lib/trantor/data}"
|
|
19
|
+
|
|
20
|
+
PG_PASSWORD=""
|
|
21
|
+
while [ $# -gt 0 ]; do
|
|
22
|
+
case "$1" in
|
|
23
|
+
--pg-password) PG_PASSWORD="$2"; shift 2 ;;
|
|
24
|
+
-p) PG_PASSWORD="$2"; shift 2 ;;
|
|
25
|
+
*) echo "unknown flag: $1"; exit 1 ;;
|
|
26
|
+
esac
|
|
27
|
+
done
|
|
28
|
+
|
|
29
|
+
if [ ! -f "$BACKUP_FILE" ]; then
|
|
30
|
+
echo "backup file not found: $BACKUP_FILE"
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
echo "=== RESTORE from $BACKUP_FILE ==="
|
|
35
|
+
TMP="$(mktemp -d)"
|
|
36
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
37
|
+
|
|
38
|
+
tar -xzf "$BACKUP_FILE" -C "$TMP"
|
|
39
|
+
|
|
40
|
+
# Ensure psql/pg_restore are available
|
|
41
|
+
command -v psql >/dev/null 2>&1 || { echo "psql not found — install postgresql-client"; exit 1; }
|
|
42
|
+
command -v pg_restore >/dev/null 2>&1 || { echo "pg_restore not found — install postgresql-client"; exit 1; }
|
|
43
|
+
|
|
44
|
+
# Determine connection mechanism
|
|
45
|
+
CONN=""
|
|
46
|
+
if [ -n "$PG_PASSWORD" ]; then
|
|
47
|
+
export PGPASSWORD="$PG_PASSWORD"
|
|
48
|
+
CONN=(-U "$PG_USER" -h "$PG_HOST" -d "$PG_DB")
|
|
49
|
+
else
|
|
50
|
+
CONN=(-U "$PG_USER" -h "$PG_HOST" -d "$PG_DB")
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
if [ -f "$TMP/pg.dump" ]; then
|
|
54
|
+
echo "restoring Postgres..."
|
|
55
|
+
psql "${CONN[@]}" -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '$PG_DB' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true
|
|
56
|
+
psql "${CONN[@]}" -c "DROP DATABASE IF EXISTS $PG_DB;" postgres >/dev/null 2>&1 || true
|
|
57
|
+
psql "${CONN[@]}" -c "CREATE DATABASE $PG_DB OWNER $PG_USER;" postgres >/dev/null 2>&1 || true
|
|
58
|
+
pg_restore --no-owner --no-privileges -U "$PG_USER" -h "$PG_HOST" -d "$PG_DB" -c "$TMP/pg.dump"
|
|
59
|
+
else
|
|
60
|
+
echo "(no pg.dump in backup — skipping Postgres restore)"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
if [ -d "$TMP/hub-data" ]; then
|
|
64
|
+
echo "restoring hub data..."
|
|
65
|
+
rm -rf "$DATA_DIR"
|
|
66
|
+
cp -r "$TMP/hub-data" "$DATA_DIR"
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
echo "=== restore complete ==="
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# trantor TIME-based retention — prune old events from Postgres.
|
|
3
|
+
# Keeps projections (tasks, messages, peers) — invariant 5 from store-contract.
|
|
4
|
+
# Run via cron: 0 3 * * * /opt/trantor/deploy/retention.sh
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
RETENTION_DAYS="${RETENTION_DAYS:-90}"
|
|
8
|
+
CUTOFF_MS="$(node -e "const d=Number(process.env.RETENTION_DAYS||90); process.stdout.write(String(Date.now() - d*864e5))")"
|
|
9
|
+
|
|
10
|
+
docker exec trantor-pg psql -U trantor -d trantor -c "
|
|
11
|
+
DELETE FROM events WHERE ts < ${CUTOFF_MS};
|
|
12
|
+
" 2>&1 | tail -1
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# trantor — netcup Debian 13 one-shot server setup. Run as root.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
echo "=== trantor netcup setup (node $(node --version)) ==="
|
|
6
|
+
|
|
7
|
+
if ! id trantor &>/dev/null; then
|
|
8
|
+
useradd -r -m -d /var/lib/trantor -s /usr/sbin/nologin trantor
|
|
9
|
+
echo "created trantor user"
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
install -d -o trantor -g trantor -m 750 /opt/trantor
|
|
13
|
+
install -d -o trantor -g trantor -m 750 /var/lib/trantor/data
|
|
14
|
+
install -d -o trantor -g trantor -m 750 /var/lib/trantor/backups
|
|
15
|
+
|
|
16
|
+
if [ ! -f /opt/trantor/hub.mjs ]; then
|
|
17
|
+
echo "copy repo to /opt/trantor first (e.g. rsync ./ netcup:/opt/trantor/), then re-run"
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
chown -R trantor:trantor /opt/trantor
|
|
21
|
+
|
|
22
|
+
# secrets: generate Postgres password, write to /etc/trantor/env
|
|
23
|
+
# Secrets live in /etc/trantor/pg.env — the EXACT path trantor-hub.service reads via
|
|
24
|
+
# EnvironmentFile. That file is prefixed `-`, so a name mismatch fails SILENTLY: the hub would boot
|
|
25
|
+
# with no DATABASE_URL and no RELAY_STORE, quietly ignoring the Postgres we just provisioned.
|
|
26
|
+
# It must also carry RELAY_STORE=pg — lib/store-pg.mjs is only selected when that is set.
|
|
27
|
+
if [ ! -f /etc/trantor/pg.env ]; then
|
|
28
|
+
PG_PASS="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
|
|
29
|
+
install -d -m 750 /etc/trantor
|
|
30
|
+
# One-time bootstrap secret for the FIRST admin. A remote hub binds non-loopback, so it must run
|
|
31
|
+
# enforce+invite — but minting an invite needs an enrolled owner, and there is none. This token is
|
|
32
|
+
# accepted ONLY while the identity store is empty; the first enrollment closes the path for good.
|
|
33
|
+
BOOT_TOK="$(openssl rand -hex 24)"
|
|
34
|
+
cat > /etc/trantor/pg.env <<EOF
|
|
35
|
+
PG_PASSWORD=$PG_PASS
|
|
36
|
+
RELAY_STORE=pg
|
|
37
|
+
DATABASE_URL=postgresql://trantor:$PG_PASS@127.0.0.1:5432/trantor
|
|
38
|
+
RELAY_BOOTSTRAP_TOKEN=$BOOT_TOK
|
|
39
|
+
EOF
|
|
40
|
+
chmod 600 /etc/trantor/pg.env
|
|
41
|
+
echo "wrote /etc/trantor/pg.env (RELAY_STORE=pg + DATABASE_URL)"
|
|
42
|
+
fi
|
|
43
|
+
source /etc/trantor/pg.env
|
|
44
|
+
export PG_PASSWORD DATABASE_URL RELAY_STORE
|
|
45
|
+
|
|
46
|
+
# install dependencies (node 20 native — no build tools needed)
|
|
47
|
+
cd /opt/trantor
|
|
48
|
+
npm install --omit=dev --no-audit --no-fund 2>/dev/null || true
|
|
49
|
+
chown -R trantor:trantor node_modules
|
|
50
|
+
|
|
51
|
+
# Dockerised Postgres
|
|
52
|
+
echo "starting Postgres..."
|
|
53
|
+
docker compose -f deploy/docker-compose.yml up -d
|
|
54
|
+
RETRIES=30
|
|
55
|
+
until docker exec trantor-pg pg_isready -U trantor -d trantor 2>/dev/null; do
|
|
56
|
+
sleep 1; ((RETRIES--))
|
|
57
|
+
if [ "$RETRIES" -le 0 ]; then echo "Postgres failed to start after 30s"; exit 1; fi
|
|
58
|
+
done
|
|
59
|
+
echo "Postgres ready"
|
|
60
|
+
|
|
61
|
+
# apply schema (from store-contract.mjs)
|
|
62
|
+
echo "applying schema..."
|
|
63
|
+
SCHEMA_SQL="$(node --input-type=module --eval '
|
|
64
|
+
import { SCHEMA_SQL } from "./lib/store-contract.mjs";
|
|
65
|
+
console.log(SCHEMA_SQL);
|
|
66
|
+
' 2>/dev/null || true)"
|
|
67
|
+
if [ -n "$SCHEMA_SQL" ]; then
|
|
68
|
+
echo "$SCHEMA_SQL" | docker exec -i trantor-pg psql -U trantor -d trantor 2>/dev/null || true
|
|
69
|
+
fi
|
|
70
|
+
echo " schema applied"
|
|
71
|
+
|
|
72
|
+
# systemd unit
|
|
73
|
+
cp deploy/trantor-hub.service /etc/systemd/system/trantor-hub.service
|
|
74
|
+
systemctl daemon-reload
|
|
75
|
+
systemctl enable --now trantor-hub
|
|
76
|
+
|
|
77
|
+
# crons: daily backup + nightly retention
|
|
78
|
+
chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh
|
|
79
|
+
echo "0 2 * * * root /opt/trantor/deploy/backup.sh" > /etc/cron.d/trantor
|
|
80
|
+
echo "0 3 * * * root /opt/trantor/deploy/retention.sh" >> /etc/cron.d/trantor
|
|
81
|
+
|
|
82
|
+
echo ""
|
|
83
|
+
echo "=== done ==="
|
|
84
|
+
echo "Hub: http://100.79.242.104:4477"
|
|
85
|
+
echo "Logs: journalctl -u trantor-hub -f"
|
|
86
|
+
echo "Backups: /var/lib/trantor/backups/"
|
|
87
|
+
echo "Restore: bash /opt/trantor/deploy/restore.sh <file>"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# trantor hub — systemd service for Debian 13 (netcup).
|
|
2
|
+
# Install: sudo cp deploy/trantor-hub.service /etc/systemd/system/ && sudo systemctl daemon-reload
|
|
3
|
+
|
|
4
|
+
[Unit]
|
|
5
|
+
Description=trantor relay hub (agent bus + dashboard)
|
|
6
|
+
After=network-online.target docker.service
|
|
7
|
+
Wants=network-online.target
|
|
8
|
+
Requires=docker.service
|
|
9
|
+
|
|
10
|
+
[Service]
|
|
11
|
+
Type=simple
|
|
12
|
+
User=trantor
|
|
13
|
+
Group=trantor
|
|
14
|
+
WorkingDirectory=/opt/trantor
|
|
15
|
+
ExecStart=/usr/bin/node /opt/trantor/hub.mjs
|
|
16
|
+
Restart=always
|
|
17
|
+
RestartSec=10
|
|
18
|
+
|
|
19
|
+
# Bind tailnet IP 100.79.242.104 (non-loopback).
|
|
20
|
+
# hub.mjs L39-42 REFUSES non-loopback unless RELAY_AUTH=enforce.
|
|
21
|
+
Environment=RELAY_HOST=100.79.242.104
|
|
22
|
+
Environment=RELAY_PORT=4477
|
|
23
|
+
Environment=RELAY_AUTH=enforce
|
|
24
|
+
Environment=RELAY_ENROLL=invite
|
|
25
|
+
|
|
26
|
+
# Data directory — persistent, outside the repo.
|
|
27
|
+
Environment=RELAY_DATA_DIR=/var/lib/trantor/data
|
|
28
|
+
|
|
29
|
+
# TIME-based retention controls (all env-overridable; defaults shown).
|
|
30
|
+
Environment=RELAY_EVENT_CAP=50000
|
|
31
|
+
Environment=RELAY_ONLINE_MS=300000
|
|
32
|
+
Environment=RELAY_PEER_TTL_MS=21600000
|
|
33
|
+
Environment=RELAY_REAP_GRACE_MS=900000
|
|
34
|
+
|
|
35
|
+
# Postgres connection (pg-store lands via #3931).
|
|
36
|
+
EnvironmentFile=-/etc/trantor/pg.env
|
|
37
|
+
|
|
38
|
+
StandardOutput=journal
|
|
39
|
+
StandardError=journal
|
|
40
|
+
SyslogIdentifier=trantor-hub
|
|
41
|
+
|
|
42
|
+
# Security hardening
|
|
43
|
+
NoNewPrivileges=yes
|
|
44
|
+
ProtectSystem=strict
|
|
45
|
+
ProtectHome=yes
|
|
46
|
+
ReadWritePaths=/var/lib/trantor /tmp
|
|
47
|
+
ReadOnlyPaths=/opt/trantor
|
|
48
|
+
|
|
49
|
+
[Install]
|
|
50
|
+
WantedBy=multi-user.target
|
package/hooks/agent-notify.mjs
CHANGED
|
@@ -12,21 +12,14 @@
|
|
|
12
12
|
// (session_id + notification_type + message are the confirmed fields). We read several candidates and let the
|
|
13
13
|
// hub key/guard on whatever we send; TRANTOR_DEBUG_NOTIFY=1 dumps the raw payload to stderr so the first real
|
|
14
14
|
// firing reveals the true shape. Fail-silent: a notification must never block the session.
|
|
15
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
15
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
16
|
+
import { signedPost } from "./lib/api.mjs";
|
|
19
17
|
|
|
20
18
|
function readStdin() {
|
|
21
19
|
return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
|
|
22
20
|
process.stdin.on("data", c => (d += c)); process.stdin.on("end", () => res(d));
|
|
23
21
|
setTimeout(() => res(d), 100); });
|
|
24
22
|
}
|
|
25
|
-
function relayUrl() {
|
|
26
|
-
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
27
|
-
try { const c = join(homedir(), ".agent-bus", "config.json"); if (existsSync(c)) { const u = JSON.parse(readFileSync(c, "utf8")).url; if (u) return u; } } catch {}
|
|
28
|
-
return "http://127.0.0.1:4477";
|
|
29
|
-
}
|
|
30
23
|
|
|
31
24
|
try {
|
|
32
25
|
const input = JSON.parse((await readStdin()) || "{}");
|
|
@@ -48,15 +41,11 @@ try {
|
|
|
48
41
|
const msg = String(input.message || "").replace(/\s+/g, " ").trim().slice(0, 90);
|
|
49
42
|
const title = `${agentType}${msg ? `: ${msg}` : ""}`.slice(0, 180);
|
|
50
43
|
|
|
51
|
-
await
|
|
52
|
-
method: "POST", headers: { "content-type": "application/json" },
|
|
53
|
-
body: JSON.stringify({
|
|
44
|
+
await signedPost("/task", {
|
|
54
45
|
project, source: "cc-bg-agent", notificationType: ntype,
|
|
55
46
|
title, agentId, agentType, parent,
|
|
56
47
|
assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`, phase: "sub-agents",
|
|
57
|
-
})
|
|
58
|
-
signal: AbortSignal.timeout(1500),
|
|
59
|
-
}).catch(() => {});
|
|
48
|
+
});
|
|
60
49
|
} catch (e) {
|
|
61
50
|
process.stderr.write(`[trantor] agent-notify error: ${e?.message || e}\n`);
|
|
62
51
|
}
|