Dashboard (content library, publishing queue with approvals, inbox, team management, AI runs), Meta connector, branded PDF reports, agent skills, and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS, backup and path-relocation scripts). Client data, live data and secrets are excluded via .gitignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
8.4 KiB
JavaScript
195 lines
8.4 KiB
JavaScript
// Runs the project's Claude Code skills (account-audit, weekly-plan, weekly-report, …) headlessly
|
|
// through the Claude Agent SDK, one job at a time, with a strict tool allow-list.
|
|
// Publishing tools are always blocked: nothing goes live from the dashboard.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
import { PROJECT_ROOT, DATA_DIR, AI } from "./config.js";
|
|
import { getClient, listDocs, safeSlug } from "./clients.js";
|
|
import { toPdf } from "../../tools/report-pdf/build.mjs";
|
|
|
|
const JOB_DIR = path.join(DATA_DIR, "jobs");
|
|
fs.mkdirSync(JOB_DIR, { recursive: true });
|
|
|
|
export const JOB_TYPES = {
|
|
"account-audit": { label: "Account audit", needsClient: true, prompt: (c) => `/account-audit ${c.name}` },
|
|
"weekly-plan": { label: "Weekly content plan", needsClient: true, prompt: (c) => `/weekly-plan ${c.name}` },
|
|
"weekly-report": { label: "Weekly report", needsClient: true, prompt: (c) => `/weekly-report ${c.name}` },
|
|
"assign-tasks": { label: "Assign this week's tasks", needsClient: false, prompt: () => `/assign-tasks` },
|
|
custom: { label: "Custom request", needsClient: false, prompt: (c, text) => (c ? `Client: ${c.name} (clients/${c.slug}/).\n\n${text}` : text) },
|
|
};
|
|
|
|
const HEADLESS_RULES = `You are running headlessly from the agency dashboard. Nobody can answer questions mid-run.
|
|
- Don't stop to ask. Make sensible assumptions, finish the job, and list open questions for the team inside the files you write (and in your final message).
|
|
- Never publish, schedule or post anything, and never create or change records in Odoo from this run.
|
|
- Only write inside clients/, team/ and reports/. Export finished client-facing reports to PDF with the project's PDF builder.
|
|
- End with a short summary: what you produced (file paths) and anything the team must check.`;
|
|
|
|
const READ_TOOLS = [
|
|
"Read", "Glob", "Grep", "WebSearch", "WebFetch", "Skill", "TodoWrite",
|
|
"Edit(clients/**)", "Write(clients/**)", "Edit(team/**)", "Write(team/**)", "Edit(reports/**)", "Write(reports/**)",
|
|
"Bash(node tools/report-pdf/build.mjs:*)",
|
|
"mcp__social-manager__list_accounts", "mcp__social-manager__instagram_insights", "mcp__social-manager__instagram_recent_posts",
|
|
"mcp__social-manager__instagram_comments", "mcp__social-manager__facebook_page_insights", "mcp__social-manager__facebook_recent_posts",
|
|
"mcp__odoo-mcp__search_records", "mcp__odoo-mcp__read_records", "mcp__odoo-mcp__read_record_by_id", "mcp__odoo-mcp__count_records",
|
|
];
|
|
const BLOCKED_TOOLS = ["mcp__social-manager__instagram_publish", "mcp__social-manager__facebook_publish"];
|
|
|
|
const jobs = new Map();
|
|
const queue = [];
|
|
let running = null;
|
|
|
|
const jobFile = (id) => path.join(JOB_DIR, `${id}.json`);
|
|
const persist = (job) => fs.writeFileSync(jobFile(job.id), JSON.stringify({ ...job, abort: undefined }));
|
|
|
|
// Load history; anything that was mid-run when the server stopped is marked interrupted.
|
|
for (const f of fs.readdirSync(JOB_DIR).filter((x) => x.endsWith(".json"))) {
|
|
try {
|
|
const job = JSON.parse(fs.readFileSync(path.join(JOB_DIR, f), "utf8"));
|
|
if (job.status === "running" || job.status === "queued") Object.assign(job, { status: "interrupted", finishedAt: job.finishedAt || new Date().toISOString() });
|
|
jobs.set(job.id, job);
|
|
} catch { /* ignore corrupt job files */ }
|
|
}
|
|
|
|
const publicJob = ({ abort, ...job }, { withLog = false } = {}) => (withLog ? job : { ...job, log: undefined, logLines: job.log?.length || 0 });
|
|
|
|
export function listJobs({ slug } = {}) {
|
|
return [...jobs.values()]
|
|
.filter((j) => !slug || j.slug === slug)
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
.slice(0, 100)
|
|
.map((j) => publicJob(j));
|
|
}
|
|
|
|
export function getJob(id) {
|
|
const job = jobs.get(id);
|
|
return job ? publicJob(job, { withLog: true }) : null;
|
|
}
|
|
|
|
export function createJob({ type, slug, text }, user) {
|
|
const def = JOB_TYPES[type];
|
|
if (!def) throw Object.assign(new Error("Unknown job type."), { status: 400 });
|
|
const client = slug ? (safeSlug(slug), getClient(slug)) : null;
|
|
if (def.needsClient && !client) throw Object.assign(new Error("Pick a client."), { status: 400 });
|
|
if (type === "custom" && !String(text || "").trim()) throw Object.assign(new Error("Describe what you need."), { status: 400 });
|
|
const job = {
|
|
id: crypto.randomUUID(),
|
|
type,
|
|
label: def.label,
|
|
slug: client?.slug || null,
|
|
clientName: client?.name || null,
|
|
prompt: def.prompt(client, String(text || "").trim()),
|
|
status: "queued",
|
|
createdBy: user.email,
|
|
createdAt: new Date().toISOString(),
|
|
log: [],
|
|
};
|
|
jobs.set(job.id, job);
|
|
persist(job);
|
|
queue.push(job.id);
|
|
pump();
|
|
return publicJob(job);
|
|
}
|
|
|
|
export function cancelJob(id) {
|
|
const job = jobs.get(id);
|
|
if (!job) return null;
|
|
if (job.status === "queued") {
|
|
queue.splice(queue.indexOf(id), 1);
|
|
Object.assign(job, { status: "cancelled", finishedAt: new Date().toISOString() });
|
|
persist(job);
|
|
} else if (job.status === "running") job.abort?.abort();
|
|
return publicJob(job);
|
|
}
|
|
|
|
function log(job, kind, text) {
|
|
job.log.push({ t: new Date().toISOString(), kind, text: String(text).slice(0, 4000) });
|
|
if (job.log.length % 5 === 0) persist(job);
|
|
}
|
|
|
|
function describeTool(block) {
|
|
const i = block.input || {};
|
|
const target = i.file_path || i.path || i.pattern || i.command || i.skill || i.query || i.url || i.account || "";
|
|
return `${block.name.replace(/^mcp__([^_]+(?:-[^_]+)*)__/, "$1 · ")}${target ? ` — ${String(target).replace(PROJECT_ROOT, "").replace(/^[\\/]+/, "").replace(/\\/g, "/").slice(0, 160)}` : ""}`;
|
|
}
|
|
|
|
function docStamp(slug) {
|
|
if (!slug) return new Map();
|
|
return new Map(listDocs(slug).map((d) => [d.path, d.updatedAt]));
|
|
}
|
|
|
|
async function run(job) {
|
|
Object.assign(job, { status: "running", startedAt: new Date().toISOString() });
|
|
job.abort = new AbortController();
|
|
persist(job);
|
|
const before = docStamp(job.slug);
|
|
log(job, "info", `Started: ${job.prompt.split("\n")[0]}`);
|
|
try {
|
|
for await (const msg of query({
|
|
prompt: job.prompt,
|
|
options: {
|
|
cwd: PROJECT_ROOT,
|
|
settingSources: ["project"],
|
|
skills: "all",
|
|
model: AI.model,
|
|
maxTurns: AI.maxTurns,
|
|
permissionMode: "dontAsk",
|
|
allowedTools: READ_TOOLS,
|
|
disallowedTools: BLOCKED_TOOLS,
|
|
abortController: job.abort,
|
|
systemPrompt: { type: "preset", preset: "claude_code", append: HEADLESS_RULES },
|
|
stderr: (data) => { if (/error/i.test(data)) log(job, "stderr", data.trim()); },
|
|
},
|
|
})) {
|
|
if (msg.type === "assistant") {
|
|
for (const block of msg.message?.content || []) {
|
|
if (block.type === "text" && block.text.trim()) log(job, "text", block.text.trim());
|
|
else if (block.type === "tool_use") log(job, "tool", describeTool(block));
|
|
}
|
|
} else if (msg.type === "result") {
|
|
job.costUsd = msg.total_cost_usd ?? null;
|
|
job.turns = msg.num_turns ?? null;
|
|
if (msg.subtype === "success" && !msg.is_error) {
|
|
job.result = msg.result;
|
|
job.status = "succeeded";
|
|
} else {
|
|
job.status = "failed";
|
|
job.error = msg.result || msg.subtype;
|
|
}
|
|
}
|
|
}
|
|
if (job.status === "running") job.status = "succeeded";
|
|
} catch (e) {
|
|
job.status = job.abort.signal.aborted ? "cancelled" : "failed";
|
|
job.error = e.message;
|
|
log(job, "error", e.message);
|
|
}
|
|
|
|
// Which documents changed? Make sure each changed client/internal report has a fresh PDF.
|
|
if (job.slug) {
|
|
const changed = listDocs(job.slug).filter((d) => d.kind !== "setup" && before.get(d.path) !== d.updatedAt);
|
|
job.files = changed.map((d) => d.path);
|
|
for (const d of changed.filter((x) => (x.kind === "audit" || x.kind === "report") && (!x.pdf || x.pdf.stale))) {
|
|
try {
|
|
toPdf(path.join(safeSlug(job.slug), d.path));
|
|
log(job, "info", `PDF exported: ${d.path.replace(/\.md$/, ".pdf")}`);
|
|
} catch (e) {
|
|
log(job, "error", `PDF export failed for ${d.path}: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
job.finishedAt = new Date().toISOString();
|
|
log(job, "info", `Finished: ${job.status}${job.costUsd != null ? ` · $${job.costUsd.toFixed(2)}` : ""}`);
|
|
delete job.abort;
|
|
persist(job);
|
|
}
|
|
|
|
async function pump() {
|
|
if (running || !queue.length) return;
|
|
const job = jobs.get(queue.shift());
|
|
if (!job || job.status !== "queued") return pump();
|
|
running = job;
|
|
try { await run(job); } finally { running = null; pump(); }
|
|
}
|