Adopt the agents/ architecture proven on medellin.co (reference impl): - Move the content engine to a top-level agents/ dir: orchestrators, prompts, config, run.sh, admin console, shared libs. All content-pipeline literals repointed (config paths, scripts, admin, LLM-facing prompts/image.md string, configure.mjs, new-site.sh, astroagent tokenFile, .gitignore runtime block). - Every script carries a parseable @agent-manifest header: name, title, class (content|operational|runtime|plumbing), trigger, model, prompts, skills (MCP), tools, reads/writes tables. 5 content agents + 3 plumbing scripts. - New agents/catalog.mjs generates the catalog from the headers: agents/AGENTS.md (human, grouped by class) + agents/agents.json (machine manifest — a clone diffs it against a source to find missing tools/tables/MCP before running). configure.mjs regenerates the catalog on every identity stamp. No DB table, no watcher. - config.json gains paths.stateDir/newsDir; publish-tick, write-daily, and news-radar read them instead of hardcoding. - Full cut: content-pipeline/ deleted (the seed has no live crons, so no hybrid period needed). Docs updated (AGENTS.md structure + pipeline section, README paths). Clones migrating from content-pipeline/: see medellin.co's .memory/handoffs/agents-directory-migration.md for the cutover playbook (one cron set active at a time; migrate drafts/state after repointing cron). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
95 lines
2.8 KiB
JavaScript
95 lines
2.8 KiB
JavaScript
// Atomic, locked access to calendar.json so concurrent runs (write-daily, publish-tick,
|
|
// research refill) can't clobber each other. Every mutation re-reads under an exclusive
|
|
// lockfile, mutates, and writes — no long-held in-memory copies.
|
|
import { existsSync, readFileSync, writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
|
|
|
|
const LOCK_STALE_MS = 10 * 60 * 1000; // a lock older than this is presumed orphaned
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
async function acquire(calPath, timeoutMs = 120000) {
|
|
const lp = `${calPath}.lock`;
|
|
const start = Date.now();
|
|
for (;;) {
|
|
try {
|
|
const fd = openSync(lp, "wx"); // exclusive create — fails if it exists
|
|
writeFileSync(lp, `${process.pid} ${new Date().toISOString()}`);
|
|
closeSync(fd);
|
|
return lp;
|
|
} catch {
|
|
try {
|
|
if (Date.now() - statSync(lp).mtimeMs > LOCK_STALE_MS) {
|
|
unlinkSync(lp);
|
|
continue;
|
|
}
|
|
} catch {
|
|
/* lock vanished — retry */
|
|
}
|
|
if (Date.now() - start > timeoutMs) throw new Error(`calendar lock timeout: ${lp}`);
|
|
await sleep(200);
|
|
}
|
|
}
|
|
}
|
|
const release = (lp) => {
|
|
try {
|
|
unlinkSync(lp);
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
};
|
|
|
|
export async function withCalendarLock(calPath, fn) {
|
|
const lp = await acquire(calPath);
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
release(lp);
|
|
}
|
|
}
|
|
|
|
export const readCalendar = (calPath) =>
|
|
existsSync(calPath) ? JSON.parse(readFileSync(calPath, "utf8")) : [];
|
|
|
|
const writeCalendar = (calPath, cal) => writeFileSync(calPath, JSON.stringify(cal, null, 2));
|
|
|
|
/** Locked read-modify-write of one entry (Object.assign patch). Returns the updated entry. */
|
|
export async function updateEntry(calPath, slug, patch) {
|
|
return withCalendarLock(calPath, () => {
|
|
const cal = readCalendar(calPath);
|
|
const e = cal.find((x) => x.slug === slug);
|
|
if (e) {
|
|
Object.assign(e, patch);
|
|
writeCalendar(calPath, cal);
|
|
}
|
|
return e;
|
|
});
|
|
}
|
|
|
|
/** Locked append if the slug is not already present. */
|
|
export async function addEntry(calPath, entry) {
|
|
return withCalendarLock(calPath, () => {
|
|
const cal = readCalendar(calPath);
|
|
if (!cal.some((x) => x.slug === entry.slug)) {
|
|
cal.push(entry);
|
|
writeCalendar(calPath, cal);
|
|
}
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
/** Locked merge: append each new entry whose slug isn't present. Returns count added. */
|
|
export async function mergeEntries(calPath, entries) {
|
|
return withCalendarLock(calPath, () => {
|
|
const cal = readCalendar(calPath);
|
|
const have = new Set(cal.map((x) => x.slug));
|
|
let added = 0;
|
|
for (const e of entries) {
|
|
if (!have.has(e.slug)) {
|
|
cal.push(e);
|
|
have.add(e.slug);
|
|
added++;
|
|
}
|
|
}
|
|
writeCalendar(calPath, cal);
|
|
return added;
|
|
});
|
|
}
|