// ═══════════════════════ AUTOMATION WORKBENCH — P1 canvas ═══════════════════════
// The Automation tab. A list of saved automations, and an n8n-style editor for one: a trigger and a
// line of steps on a dotted canvas, each step a ROLE run on a PROFILE, connected so one step's output
// feeds the next step's goal ({{…}}). P1 is a LINE — nodes run in array order, edges are the chain
// between them; free-form branching is P2. Talks only to /v1/workflows + /v1/agent/roles + /v1/profiles
// through the shared api(); dashboard.js owns the nav and calls window.Automation.load().
(function () {
const el = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const uid = () => 'n' + Math.random().toString(36).slice(2, 8);
const NODE = {
trigger: { ic: '⏱', cls: 'tg', name: 'Trigger' },
agent: { ic: '🔎', cls: 'ag', name: 'Agent step' },
filter: { ic: '▽', cls: 'fl', name: 'Filter a list' },
branch: { ic: '⑂', cls: 'br', name: 'Branch (if / else)' },
store: { ic: '⛃', cls: 'st', name: 'Store a value' },
};
const OPS = [['exists', 'has a value'], ['contains', 'contains'], ['eq', 'is exactly'], ['gt', 'is greater than'], ['lt', 'is less than']];
let WORKFLOWS = [], ROLES = [], PROFILES = [], CURRENT = null, SEL = null, RUN = null, POLL = null;
let LIST_POLL = null, LIST_FILTER = 'all', RUNSIG = '';
function stopListPoll() { if (LIST_POLL) { clearInterval(LIST_POLL); LIST_POLL = null; } }
// Which automations are running right now (their latest run is still going). Returns a signature so
// the list only re-renders when that set actually changes.
async function markRunning() {
await Promise.all(WORKFLOWS.map(async (wf) => {
try {
const rr = await api('/v1/workflows/' + encodeURIComponent(wf.id) + '/runs');
const runs = rr.runs || [];
wf._running = runs.some((x) => x.status === 'running');
wf._runId = (runs.find((x) => x.status === 'running') || runs[0] || {}).id || null;
} catch (e) { wf._running = false; }
}));
return WORKFLOWS.filter((w) => w._running).map((w) => w.id).sort().join(',');
}
async function load() {
stopPoll(); stopListPoll();
try {
const [w, r, p] = await Promise.all([
api('/v1/workflows').catch(() => ({ workflows: [] })),
api('/v1/agent/roles').catch(() => ({ roles: [] })),
api('/v1/profiles').catch(() => ({ profiles: [] })),
]);
WORKFLOWS = w.workflows || [];
ROLES = r.roles || [];
PROFILES = (p.profiles || []).map((x) => (typeof x === 'string' ? x : (x.name || x.id || x.profile))).filter(Boolean);
} catch (e) { /* keep what's there */ }
CURRENT = null; SEL = null; RUN = null;
RUNSIG = await markRunning();
renderList();
// Keep the Running tab live while the list is on screen, re-rendering only when it changes.
LIST_POLL = setInterval(async () => {
if (CURRENT) return;
const sig = await markRunning();
if (sig !== RUNSIG) { RUNSIG = sig; renderList(); }
}, 7000);
}
// ── list of automations ─────────────────────────────────────────────────────────────────────────
function renderList() {
const view = el('dashAutoView'); if (!view) return;
const running = WORKFLOWS.filter((w) => w._running);
const shown = LIST_FILTER === 'running' ? running : WORKFLOWS;
view.innerHTML =
'
' +
'
AutomationsWire roles and profiles into steps that run in a line — collect, then act on it
' +
'
' +
'' +
'' +
'
' +
'
' +
'
' +
'
' +
'' +
'' +
'
' +
'
' +
'
' +
(shown.length ? shown.map((w) => {
/*
* WHAT THE FLOW DOES, counted honestly. This counted agent nodes only, so Herald's reply flow —
* post it, was it refused, is it on the thread, what happened — read as "1 step" and looked
* trivial. A step is a node that ACTS or DECIDES; a store just writes the answer down.
*/
const doing = (w.nodes || []).filter((n) => n.type !== 'trigger' && n.type !== 'store');
const steps = doing.length;
/* And what KIND of steps, so a glance says whether it browses, checks or sorts. */
const kinds = [...new Set(doing.map((n) => (n.type === 'agent' ? 'browses' : n.type === 'verify' ? 'checks the page' : n.type === 'branch' ? 'decides' : n.type)))].join(', ');
const pill = w._running
? '● running'
: '' + (w.active ? 'active' : 'draft') + '';
/* Where it came from. An organ ships its flow and starts it itself, so "manual" — which is
what the trigger says — would read as "nobody has pressed this", which is the opposite. */
const from = w.owner ? 'from ' + esc(w.owner) + '' : '';
const starter = w.owner ? 'started by ' + esc(w.owner) : esc((w.trigger && w.trigger.type) || 'manual');
// "Results" opens the findings/approvals view (the DATA) — deliberately not "Watch", which
// elsewhere in the app opens the live browser. Shown whenever a run exists to look at.
const results = w._runId ? '' : '';
return '
')
+ '';
cfg.querySelectorAll('[data-runid]').forEach((b) => b.onclick = async () => { try { RUN = await api('/v1/workflow-runs/' + encodeURIComponent(b.dataset.runid)); SEL = null; renderFlow(); renderCfg(); openRunResults(RUN); } catch (e) {} });
el('hClose') && (el('hClose').onclick = () => { RUN = null; SEL = CURRENT.nodes[0] ? CURRENT.nodes[0].id : null; renderFlow(); renderCfg(); });
}
// ── import / export a whole automation ───────────────────────────────────────────────────────
function pkModal() {
let m = el('wfPackModal');
if (m) return m;
m = document.createElement('div'); m.className = 'mkt-modal'; m.id = 'wfPackModal';
m.innerHTML = '
';
document.body.appendChild(m);
m.addEventListener('click', (e) => { if (e.target === m || e.target.closest('[data-x]')) m.classList.remove('on'); });
return m;
}
function openImport() {
const m = pkModal();
m.querySelector('.t').textContent = 'Import an automation';
m.querySelector('.s').textContent = 'Paste a workflow pack, or load a .json file.';
m.querySelector('.mbody').innerHTML = '';
m.querySelector('.mfoot').innerHTML = '';
m.classList.add('on');
el('wfFile').onchange = (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { el('wfText').value = rd.result; }; rd.readAsText(f); };
el('wfGo').onclick = async () => {
let pack; try { pack = JSON.parse(el('wfText').value); } catch (e) { el('wfErr').textContent = 'That is not valid JSON.'; return; }
try { await api('/v1/workflows/import', { method: 'POST', body: JSON.stringify(pack) }); m.classList.remove('on'); load(); }
catch (e) { el('wfErr').textContent = e.message || 'Could not import.'; }
};
}
async function exportWorkflow(id) {
let pack; try { pack = await api('/v1/workflows/' + encodeURIComponent(id) + '/export'); } catch (e) { alert('Could not export: ' + (e.message || e)); return; }
const json = JSON.stringify(pack, null, 2);
const m = pkModal();
m.querySelector('.t').textContent = 'Export "' + (pack.name || 'automation') + '"';
m.querySelector('.s').textContent = 'A shareable pack. Copy it, or download the file.';
m.querySelector('.mbody').innerHTML = '';
m.querySelector('.mfoot').innerHTML = '';
m.classList.add('on');
el('wfCopy').onclick = async () => { try { await navigator.clipboard.writeText(json); el('wfCopy').textContent = 'Copied'; setTimeout(() => el('wfCopy').textContent = 'Copy', 1400); } catch (e) { el('wfOut').select(); } };
el('wfDl').onclick = () => { try { const u = URL.createObjectURL(new Blob([json], { type: 'application/json' })); const a = document.createElement('a'); a.href = u; a.download = (String(pack.name || 'automation').replace(/[^a-z0-9_-]+/gi, '-')) + '.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(u), 2000); } catch (e) {} };
}
// ── high-end run results — a wide, filterable view of everything a run found ────────────────────
// Every role emits a DIFFERENT record shape (a scout saves leads, a gig-scout saves gigs with a
// budget, a research role saves findings). Records are freeform `{ at, ...whatever the tool saved }`,
// so nothing here assumes a field: each card reads whatever's present and lays it out by ROLE —
// a title, a body, a link, and the rest as labelled chips. New role → new fields → still renders.
let RUNVIEW = null, LEADFILTER = '', APPROVALS = [], RMPOLL = null;
const KEY_LABELS = { groupName: 'group', groupUrl: 'group', postUrl: 'post', profileUrl: 'profile', postedAt: 'posted', postText: 'post', profile: 'profile' };
const humanKey = (k) => KEY_LABELS[k] || k.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').replace(/\bUrl\b/i, '').trim().toLowerCase();
function stopResultsPoll() { if (RMPOLL) { clearInterval(RMPOLL); RMPOLL = null; } }
function pollResultsModal(runId) {
stopResultsPoll();
RMPOLL = setInterval(async () => {
const rm = el('runModal'); if (!rm || !rm.classList.contains('on')) { stopResultsPoll(); return; }
try { const r = await api('/v1/workflow-runs/' + encodeURIComponent(runId)); RUNVIEW = r; await loadApprovals(); renderRunResults(); if (r.status === 'done' || r.status === 'error') stopResultsPoll(); } catch (e) {}
}, 4000);
}
// The crafted replies waiting for a yes live on the reply jobs' proposals, not in the run record.
// Surface only the ones that belong to THE RUN BEING VIEWED — each workflow-step job is tagged with
// its runId, so a Messenger reply parked by the reply-watcher never shows under the channel-setup
// run's results (the bug that put someone's private DM in the wrong flow). Fallback for older jobs
// with no runId: match the pending job's goal against this run's step goals.
async function loadApprovals() {
APPROVALS = [];
try {
const runId = RUNVIEW && (RUNVIEW.id || RUNVIEW.run_id);
const stepGoals = new Set((RUNVIEW && RUNVIEW.steps || []).map((s) => (s.goal || '').slice(0, 200)).filter(Boolean));
const jr = await api('/v1/agent/jobs');
const mine = (jr.jobs || []).filter((j) => (j.pending || 0) > 0 && (
(j.runId && runId && j.runId === runId) ||
(!j.runId && j.goal && stepGoals.has(String(j.goal).slice(0, 200)))
));
for (const jj of mine) {
try { const jd = await api('/v1/agent/jobs/' + encodeURIComponent(jj.id)); const job = jd.job || jd;
for (const p of (job.proposals || [])) if (p.state === 'pending')
APPROVALS.push({ jobId: job.id, pid: p.pid, kind: p.kind, text: p.text || '', why: p.why || '', goal: job.goal || '' });
} catch (e) {}
}
} catch (e) {}
}
async function decideApproval(jobId, pid, approve, edit) {
const body = { approve }; if (edit != null) body.edit = edit;
try { await api('/v1/agent/jobs/' + encodeURIComponent(jobId) + '/proposals/' + encodeURIComponent(pid), { method: 'POST', body: JSON.stringify(body) }); } catch (e) {}
await loadApprovals(); renderRunResults();
}
// After firing a one-off action (a Messenger opener), poll approvals for a few minutes so its drafted
// message surfaces in the queue at the top of this same view — no need to hunt in the session chat.
function startApprovalWatch() {
stopResultsPoll();
let ticks = 0;
RMPOLL = setInterval(async () => {
const rm = el('runModal'); if (!rm || !rm.classList.contains('on') || ticks++ > 45) { stopResultsPoll(); return; }
try { await loadApprovals(); renderRunResults(); } catch (e) {}
}, 4000);
}
// Named record collections a step can emit (see makeRunAgent.pruneOut), each with a human label.
const RECORD_SETS = [
{ key: 'leads', label: 'Leads', open: 'Open the post →' },
{ key: 'gigs', label: 'Gigs', open: 'Open the brief →' },
{ key: 'reach', label: 'Reach', open: 'Open →' },
{ key: 'opportunities', label: 'Opportunities', open: 'Open →' },
];
const STRING_SETS = [ { key: 'keywords', label: 'Keywords' }, { key: 'searchQueries', label: 'Searches' } ];
// Which field plays which role on a card — first one present wins. Order = priority. These cover
// the shapes roles emit today and degrade gracefully for ones they don't: a title, a short reason,
// a verbatim quote, a way to make contact, and links. Anything left over still shows as a chip.
const TITLE_KEYS = ['name', 'who', 'handle', 'title', 'subject', 'company', 'author', 'from', 'client', 'term', 'query', 'q'];
const BODY_KEYS = ['why', 'summary', 'what', 'reason', 'assessment', 'note', 'fit', 'description', 'desc'];
const QUOTE_KEYS = ['quote', 'postText', 'text', 'message', 'brief', 'snippet', 'excerpt', 'post', 'body'];
const CONTACT_KEYS = ['email', 'phone', 'website', 'contact'];
const LINK_KEYS = ['url', 'link', 'permalink', 'postUrl', 'profileUrl', 'groupUrl', 'href', 'profile'];
const HIDE_KEYS = ['__jobId', 'id', '_id'];
const fmtVal = (v) => Array.isArray(v) ? v.join(', ') : (v && typeof v === 'object') ? JSON.stringify(v) : String(v);
const pick = (rec, keys, used) => { for (const k of keys) if (!used.has(k) && rec[k] != null && rec[k] !== '') return { k, v: rec[k] }; return null; };
function contactHref(k, v) {
const s = String(v).trim();
if (k === 'email' || (k === 'contact' && /@/.test(s))) return 'mailto:' + s;
if (k === 'phone') { const d = s.replace(/[^\d+]/g, ''); return d ? 'tel:' + d : null; }
if (/^https?:\/\//i.test(s)) return s;
if (k === 'website') return 'http://' + s.replace(/^\/*/, '');
return null;
}
function runResultsModal() {
let m = el('runModal'); if (m) return m;
m = document.createElement('div'); m.className = 'run-modal'; m.id = 'runModal';
m.innerHTML = '
';
document.body.appendChild(m);
m.addEventListener('click', (e) => { if (e.target === m || e.target.closest('[data-x]')) { m.classList.remove('on'); stopResultsPoll(); } });
return m;
}
// Adaptive card: reads whatever fields a record carries and places each by the role it plays —
// title, short reason, a verbatim quote block, a contact row, meta chips, and links. Nothing is
// hidden: any leftover scalar becomes a chip (long ones truncated), so an unfamiliar shape from a
// new role still renders completely rather than losing fields.
function recordCard(rec, openLabel) {
if (rec == null) return '';
if (typeof rec !== 'object') return '
' + esc(fmtVal(rec)) + '
';
const used = new Set(HIDE_KEYS);
const t = pick(rec, TITLE_KEYS, used); if (t) used.add(t.k);
const b = pick(rec, BODY_KEYS, used); if (b) used.add(b.k);
const q = pick(rec, QUOTE_KEYS, used); if (q) used.add(q.k);
for (const k of QUOTE_KEYS) used.add(k); // the other verbatim fields duplicate the quote — don't chip them too
const link = pick(rec, LINK_KEYS, used); if (link) used.add(link.k);
const when = rec.at ? fmtTime(rec.at) : ''; used.add('at');
const contacts = [];
for (const k of CONTACT_KEYS) { if (used.has(k)) continue; const v = rec[k]; if (v == null || v === '') continue; used.add(k);
const href = contactHref(k, v);
contacts.push(href ? '' + esc(String(v)) + ''
: '' + esc(String(v)) + ''); }
const links = [];
for (const k of LINK_KEYS) { if (used.has(k)) continue; const v = rec[k]; if (!v) continue; used.add(k);
links.push('' + esc(k.replace(/Url$/, '')) + ' →'); }
const chips = [];
for (const k of Object.keys(rec)) { if (used.has(k)) continue; const v = rec[k]; if (v == null || v === '') continue;
if (typeof v === 'object') continue; let s = fmtVal(v); if (s.length > 52) s = s.slice(0, 50) + '…';
chips.push('' + esc(humanKey(k)) + ' ' + esc(s) + ''); }
const title = t ? fmtVal(t.v) : (b || q ? '' : 'Record');
// A Messenger contact record gets a "Message" action — pick it, guide the AI, it opens the chat.
const isMsg = String(rec.platform || '').toLowerCase() === 'messenger' || /\/messages\//.test(String(rec.postUrl || rec.url || ''));
const msgBtn = isMsg ? '' : '';
return '