arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
const WorkflowViewer = ({ onNav }) => {
|
||||
const nodes = [
|
||||
{ id: 'trigger', x: 60, y: 260, title: 'Weekly Schedule', type: 'trigger', badge: 'CRON', icon: 'clock', tone: '#22C55E',
|
||||
inputs: [], outputs: [{k: 'timestamp', t: 'ISO8601'}, {k: 'runId', t: 'string'}] },
|
||||
{ id: 'fetch', x: 320, y: 140, title: 'Fetch Accounts', type: 'database.query', badge: 'DB', icon: 'database', tone: '#3B82F6',
|
||||
inputs: [{k: 'segment', t: 'string'}], outputs: [{k: 'accounts', t: 'Account[]'}, {k: 'count', t: 'number'}] },
|
||||
{ id: 'events', x: 320, y: 380, title: 'Pull Events', type: 'segment.events', badge: 'API', icon: 'bolt', tone: '#F59E0B',
|
||||
inputs: [{k: 'since', t: 'ISO8601'}], outputs: [{k: 'events', t: 'Event[]'}] },
|
||||
{ id: 'summarize', x: 600, y: 260, title: 'Summarize with Claude', type: 'ai.completion', badge: 'AI', icon: 'spark', tone: '#8B5CF6',
|
||||
inputs: [{k: 'accounts', t: 'Account[]'}, {k: 'events', t: 'Event[]'}, {k: 'prompt', t: 'string'}],
|
||||
outputs: [{k: 'digest', t: 'Digest'}, {k: 'tokens', t: 'number'}] },
|
||||
{ id: 'filter', x: 880, y: 160, title: 'Filter — priority ≥ 2', type: 'logic.filter', badge: 'IF', icon: 'filter', tone: '#64748B',
|
||||
inputs: [{k: 'digest', t: 'Digest'}], outputs: [{k: 'items', t: 'Item[]'}] },
|
||||
{ id: 'slack', x: 1140, y: 100, title: 'Post to #revenue', type: 'slack.message', badge: 'OUT', icon: 'slack', tone: '#EC4899',
|
||||
inputs: [{k: 'channel', t: 'string'}, {k: 'blocks', t: 'Block[]'}], outputs: [{k: 'ts', t: 'string'}] },
|
||||
{ id: 'mail', x: 1140, y: 260, title: 'Email Digest', type: 'mail.send', badge: 'OUT', icon: 'mail', tone: '#6366F1',
|
||||
inputs: [{k: 'to', t: 'string[]'}, {k: 'subject', t: 'string'}, {k: 'html', t: 'string'}], outputs: [{k: 'messageId', t: 'string'}] },
|
||||
{ id: 'log', x: 880, y: 400, title: 'Log run metadata', type: 'arcrun.log', badge: 'LOG', icon: 'terminal', tone: '#475569',
|
||||
inputs: [{k: 'runId', t: 'string'}, {k: 'stats', t: 'Stats'}], outputs: [] },
|
||||
];
|
||||
|
||||
const edges = [
|
||||
['trigger', 'fetch'],
|
||||
['trigger', 'events'],
|
||||
['fetch', 'summarize'],
|
||||
['events', 'summarize'],
|
||||
['summarize', 'filter'],
|
||||
['summarize', 'log'],
|
||||
['filter', 'slack'],
|
||||
['filter', 'mail'],
|
||||
];
|
||||
|
||||
const [selectedId, setSelectedId] = React.useState('summarize');
|
||||
const [title, setTitle] = React.useState('digest/weekly');
|
||||
const [zoom, setZoom] = React.useState(100);
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedId);
|
||||
|
||||
// Edit triplet inline (for the summarize node's prompt config)
|
||||
const [triplet, setTriplet] = React.useState({
|
||||
model: 'claude-haiku-4-5',
|
||||
temperature: '0.3',
|
||||
prompt: 'Summarize this week\'s account activity for the revenue team.',
|
||||
});
|
||||
|
||||
// Measure node widths for edge endpoint accuracy
|
||||
const nodeRefs = React.useRef({});
|
||||
const [sizes, setSizes] = React.useState({});
|
||||
React.useEffect(() => {
|
||||
const ns = {};
|
||||
for (const n of nodes) {
|
||||
const el = nodeRefs.current[n.id];
|
||||
if (el) ns[n.id] = { w: el.offsetWidth, h: el.offsetHeight };
|
||||
}
|
||||
setSizes(ns);
|
||||
}, []);
|
||||
|
||||
const getPort = (id, side) => {
|
||||
const n = nodes.find(x => x.id === id);
|
||||
const sz = sizes[id] || { w: 200, h: 60 };
|
||||
return {
|
||||
x: side === 'out' ? n.x + sz.w : n.x,
|
||||
y: n.y + sz.h / 2,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wf-viewer">
|
||||
<div className="wf-topbar">
|
||||
<div className="back" onClick={() => onNav('dashboard')} title="Back to dashboard">
|
||||
<Icon name="arrow_left" size={16} />
|
||||
</div>
|
||||
<Logo size="sm" onClick={() => onNav('landing')} />
|
||||
<div className="sep" />
|
||||
<div className="wf-breadcrumb">
|
||||
<span className="cr" onClick={() => onNav('dashboard')}>Workflows</span>
|
||||
<Icon name="chevron_right" size={11} />
|
||||
<input
|
||||
className="wf-title mono"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="wf-saved">
|
||||
<span style={{width: 6, height: 6, borderRadius: '50%', background: '#22C55E', boxShadow: '0 0 0 3px rgba(34,197,94,0.18)'}} />
|
||||
Saved · 2m ago
|
||||
</span>
|
||||
<div className="spacer" />
|
||||
<button className="btn btn-ghost btn-sm"><Icon name="share" size={13} /> Share</button>
|
||||
<button className="btn btn-secondary btn-sm"><Icon name="download" size={13} /> Export YAML</button>
|
||||
<button className="wf-edit-in-claude">
|
||||
<Icon name="spark" size={13} /> Edit in Claude <Icon name="external" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="wf-canvas">
|
||||
<svg className="wf-edges" width="100%" height="100%">
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#6366F1" />
|
||||
</marker>
|
||||
<marker id="arrow-dim" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#3a3a3a" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map(([a, b], i) => {
|
||||
const p1 = getPort(a, 'out');
|
||||
const p2 = getPort(b, 'in');
|
||||
const dx = Math.max(40, (p2.x - p1.x) * 0.5);
|
||||
const d = `M ${p1.x} ${p1.y} C ${p1.x + dx} ${p1.y}, ${p2.x - dx} ${p2.y}, ${p2.x - 2} ${p2.y}`;
|
||||
const highlight = a === selectedId || b === selectedId;
|
||||
return (
|
||||
<path key={i} d={d}
|
||||
stroke={highlight ? '#6366F1' : '#3a3a3a'}
|
||||
strokeWidth={highlight ? 2 : 1.5}
|
||||
fill="none"
|
||||
markerEnd={`url(#${highlight ? 'arrow' : 'arrow-dim'})`}
|
||||
opacity={highlight ? 0.95 : 0.6} />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="wf-nodes">
|
||||
{nodes.map(n => (
|
||||
<div key={n.id}
|
||||
ref={el => (nodeRefs.current[n.id] = el)}
|
||||
className={`wf-node ${selectedId === n.id ? 'selected' : ''}`}
|
||||
style={{left: n.x, top: n.y}}
|
||||
onClick={() => setSelectedId(n.id)}>
|
||||
{n.inputs.length > 0 && <span className="port in" />}
|
||||
{n.outputs.length > 0 && <span className="port out" />}
|
||||
<div className="node-row-top">
|
||||
<span className="node-icon" style={{background: n.tone}}>
|
||||
<Icon name={n.icon} size={12} />
|
||||
</span>
|
||||
<span className="node-title">{n.title}</span>
|
||||
<span className="node-badge">{n.badge}</span>
|
||||
</div>
|
||||
<div className="node-sub">{n.type}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selected && (
|
||||
<div className="wf-detail">
|
||||
<div className="dt-head">
|
||||
<span className="dt-icon" style={{background: selected.tone}}>
|
||||
<Icon name={selected.icon} size={15} />
|
||||
</span>
|
||||
<div className="dt-meta">
|
||||
<h3>{selected.title}</h3>
|
||||
<div className="dt-type">{selected.type}</div>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => setSelectedId(null)}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dt-body">
|
||||
<div className="dt-section">
|
||||
<h4>Input schema</h4>
|
||||
{selected.inputs.length === 0 ? (
|
||||
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No inputs — this is a trigger.</div>
|
||||
) : selected.inputs.map(f => (
|
||||
<div key={f.k} className="schema-field">
|
||||
<span className="k">{f.k}</span>
|
||||
<span className="t">{f.t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dt-section">
|
||||
<h4>Output schema</h4>
|
||||
{selected.outputs.length === 0 ? (
|
||||
<div style={{fontSize: 12, color: 'var(--text-mute)', fontStyle: 'italic'}}>No outputs — terminal node.</div>
|
||||
) : selected.outputs.map(f => (
|
||||
<div key={f.k} className="schema-field">
|
||||
<span className="k">{f.k}</span>
|
||||
<span className="t">{f.t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected.id === 'summarize' && (
|
||||
<div className="dt-section">
|
||||
<h4>Configuration</h4>
|
||||
<div className="triplet">
|
||||
<div className="trow">
|
||||
<div className="tkey">model</div>
|
||||
<input className="tval" value={triplet.model} onChange={e => setTriplet({...triplet, model: e.target.value})} />
|
||||
</div>
|
||||
<div className="trow">
|
||||
<div className="tkey">temp</div>
|
||||
<input className="tval" value={triplet.temperature} onChange={e => setTriplet({...triplet, temperature: e.target.value})} />
|
||||
</div>
|
||||
<div className="trow">
|
||||
<div className="tkey">prompt</div>
|
||||
<input className="tval" value={triplet.prompt} onChange={e => setTriplet({...triplet, prompt: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dt-section">
|
||||
<h4>Last run</h4>
|
||||
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12}}>
|
||||
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Duration</div>
|
||||
<div style={{fontFamily: 'JetBrains Mono, monospace', marginTop: 3}}>2.4s</div>
|
||||
</div>
|
||||
<div style={{background: 'rgba(255,255,255,0.02)', border: '1px solid var(--line)', borderRadius: 7, padding: '8px 10px'}}>
|
||||
<div style={{color: 'var(--text-mute)', fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '0.06em'}}>Status</div>
|
||||
<div style={{marginTop: 2}}><span className="pill active"><span className="pdot" />success</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary" style={{width: '100%', marginTop: 4}}>
|
||||
<Icon name="spark" size={13} /> Edit this node in Claude
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimap */}
|
||||
<div className="wf-minimap">
|
||||
<div className="mini-label">Overview</div>
|
||||
{nodes.map(n => {
|
||||
const sz = sizes[n.id] || {w: 180, h: 60};
|
||||
return (
|
||||
<div key={n.id} className="mini-box" style={{
|
||||
left: 8 + (n.x / 1400) * 164,
|
||||
top: 18 + (n.y / 500) * 80,
|
||||
width: Math.max(6, (sz.w / 1400) * 164),
|
||||
height: Math.max(4, (sz.h / 500) * 80),
|
||||
opacity: selectedId === n.id ? 1 : 0.5,
|
||||
background: selectedId === n.id ? 'var(--primary)' : 'var(--primary-soft)',
|
||||
}} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="wf-controls">
|
||||
<button onClick={() => setZoom(Math.max(40, zoom - 10))}><Icon name="zoom_out" size={13} /></button>
|
||||
<div className="zoom-val">{zoom}%</div>
|
||||
<button onClick={() => setZoom(Math.min(200, zoom + 10))}><Icon name="zoom_in" size={13} /></button>
|
||||
<button><Icon name="maximize" size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.WorkflowViewer = WorkflowViewer;
|
||||
Reference in New Issue
Block a user