Archive completed tasks, add cleanup commands, self-documenting dashboard UI
CI / build (push) Has been cancelled
CI / build (push) Has been cancelled
- Archive 79 completed framework-dev tasks from tasks/ -> tasks/complete/ - status.py: add --cleanup-done and --install-cleanup-schedule commands - Add scripts/automaton-cleanup.sh for periodic task archiving - Dashboard: rename 'Background' tab -> 'Agent', 'Cleanup' agent -> 'Completed Task Archiver', remove redundant group headers and pill badges, dim inactive agent placeholders - .rules.md: add Self-Documenting UI Names rule - New tests: test_cleanup_done.py, expanded test_app.py and test_task.py
This commit is contained in:
@@ -50,3 +50,17 @@
|
|||||||
- Write task artifacts atomically to avoid partial reads by the dashboard or other agents
|
- Write task artifacts atomically to avoid partial reads by the dashboard or other agents
|
||||||
- Prefer writing to a temporary file and renaming it into place
|
- Prefer writing to a temporary file and renaming it into place
|
||||||
- Avoid leaving empty or malformed artifacts in the task folder
|
- Avoid leaving empty or malformed artifacts in the task folder
|
||||||
|
|
||||||
|
## Self-Documenting UI Names
|
||||||
|
- All user-facing labels must be self-documenting and free of internal jargon
|
||||||
|
- A user should understand what something is without knowing the codebase internals
|
||||||
|
- Terms like "tick", "loop", "state", or "phase transition" are internal terminology — never use them directly in UI elements
|
||||||
|
- Pick names that describe what something *does*, not what it's called in code
|
||||||
|
- Past failure (1): "Loop Tick" in the Background tab — user asked "What is loop tick?" because it's scheduler jargon
|
||||||
|
- Past failure (2): "Loop" — still too vague, user said "I don't know what the loop is"
|
||||||
|
- Past failure (3): "Loop Agent" — user asked "How do I differentiate between agents with different roles?" because the name described the mechanism (loop) not the purpose
|
||||||
|
- Past failure (4): "Background" tab — the tab name described the *location* (background), not the *content* (agents). Renamed to "Agent" tab.
|
||||||
|
- Past failure (5): Group headers + agent type pill badge + icon + name all repeated the same info ("🧹 Cleanup" seen 3 times per card). Removed group headers and pill badges — only icon + name on each card.
|
||||||
|
- Past failure (6): "Cleanup" agent was vague — renamed to "Completed Task Archiver" which describes what it actually does.
|
||||||
|
- Rule: Every agent type must have a self-documenting name ending with its function (e.g., "Archiver", "Scanner", "Processor"). If multiple instances exist, list each instance card.
|
||||||
|
- Agent types with no active instances render as dimmed placeholder cards rather than being hidden, so the user always sees the full roster of available agents.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ DEFAULTS = {
|
|||||||
"theme": "default",
|
"theme": "default",
|
||||||
}
|
}
|
||||||
|
|
||||||
VALID_VIEWS = {"board", "statistics", "timeline"}
|
VALID_VIEWS = {"board", "statistics", "timeline", "agent"}
|
||||||
VALID_THEMES = {"default", "dark", "light"}
|
VALID_THEMES = {"default", "dark", "light"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -648,6 +648,8 @@ def discover_tasks(tasks_dir: Path) -> list[Task]:
|
|||||||
continue
|
continue
|
||||||
if folder_path.name == "subtasks":
|
if folder_path.name == "subtasks":
|
||||||
continue
|
continue
|
||||||
|
if folder_path.name == "complete":
|
||||||
|
continue
|
||||||
if not _VALID_TASK_NAME_CHARS.issuperset(set(folder_path.name)):
|
if not _VALID_TASK_NAME_CHARS.issuperset(set(folder_path.name)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -327,51 +327,196 @@ function renderStats() {
|
|||||||
${waveStats.length > 0 ? `<div class="stats-section"><h4>Wave Progress</h4><div class="bar-chart">${waveHtml}</div></div>` : ''}`;
|
${waveStats.length > 0 ? `<div class="stats-section"><h4>Wave Progress</h4><div class="bar-chart">${waveHtml}</div></div>` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTimeline() {
|
const AGENT_TYPE_META = {
|
||||||
const panel = document.getElementById('timeline-panel');
|
completed_task_archiver: { icon: '🧹', label: 'Completed Task Archiver', color: '#9aa0a6', desc: 'Archives completed tasks older than a threshold' },
|
||||||
const filtered = getFilteredTasks();
|
single: { icon: '🎯', label: 'Dedicated Agent', color: '#42a5f5', desc: 'Works on a single assigned task' },
|
||||||
|
audit: { icon: '🔍', label: 'Audit Scanner', color: '#ffa726', desc: 'Scans for violations and creates remediation tasks' },
|
||||||
|
backlog: { icon: '📋', label: 'Backlog Processor', color: '#66bb6a', desc: 'Processes items from a backlog queue' },
|
||||||
|
};
|
||||||
|
|
||||||
// Group phases by phase group
|
const AGENT_TYPES = Object.keys(AGENT_TYPE_META);
|
||||||
const phaseGroupStates = {};
|
const AGENT_TYPE_META_FALLBACK = { icon: '🤖', label: 'Agent', color: '#8ab4f8', desc: '' };
|
||||||
PHASE_GROUPS.forEach(group => {
|
|
||||||
phaseGroupStates[group.id] = {
|
async function fetchSchedule() {
|
||||||
states: group.states,
|
try {
|
||||||
label: group.label,
|
const res = await fetch('/api/scheduled');
|
||||||
color: group.color,
|
const data = await res.json();
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch scheduled jobs:', err);
|
||||||
|
return { jobs: [], available: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolveAgentType(job) {
|
||||||
|
if (job.kind === 'cleanup' || !job.agent_type) return 'completed_task_archiver';
|
||||||
|
return job.agent_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scheduledStatusBadge(status) {
|
||||||
|
const map = {
|
||||||
|
enabled: { text: 'Enabled', cls: 'ok' },
|
||||||
|
disabled: { text: 'Disabled', cls: 'muted' },
|
||||||
|
misconfigured: { text: 'Misconfigured', cls: 'bad' },
|
||||||
};
|
};
|
||||||
});
|
const m = map[status] || { text: status || 'Unknown', cls: 'muted' };
|
||||||
|
return `<span class="bg-status bg-status-${m.cls}">${m.text}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Legend: show phase groups
|
function _renderRuntimeState(rt) {
|
||||||
const legend = PHASE_GROUPS.map(group => {
|
if (!rt) return '';
|
||||||
return `<div class="legend-item"><span class="legend-dot" style="background: ${group.color}"></span><span>${group.label}</span></div>`;
|
const rows = [];
|
||||||
}).join('');
|
if (rt.status) rows.push(['status', rt.status]);
|
||||||
|
if (rt.iteration_count !== undefined && rt.iteration_count !== null)
|
||||||
|
rows.push(['iterations', rt.iteration_count]);
|
||||||
|
if (rt.halt_reason) rows.push(['halt_reason', rt.halt_reason]);
|
||||||
|
if (rt.current_task) rows.push(['current_task', rt.current_task]);
|
||||||
|
if (Array.isArray(rt.score_history) && rt.score_history.length)
|
||||||
|
rows.push(['scores', rt.score_history.join(', ')]);
|
||||||
|
if (rt.resumed_count) rows.push(['resumed_count', rt.resumed_count]);
|
||||||
|
if (!rows.length) return '';
|
||||||
|
return `<div class="bg-runtime"><h5>Runtime State</h5><dl>${rows.map(r =>
|
||||||
|
`<dt>${r[0]}</dt><dd>${r[1]}</dd>`).join('')}</dl></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const itemsHtml = filtered.map(task => {
|
function _agentRuntimeIndicator(rt) {
|
||||||
// For each phase group, check if the task has completed any state in that group
|
if (!rt || !rt.status) return '';
|
||||||
const phaseGroupHtml = PHASE_GROUPS.map(group => {
|
const map = {
|
||||||
const groupStates = group.states;
|
running: { icon: '▶', cls: 'ok' },
|
||||||
const hasArtifact = groupStates.some(s => task.artifacts[s]);
|
halted: { icon: '⏹', cls: 'bad' },
|
||||||
const isComplete = task.state === 'done' || hasArtifact;
|
paused: { icon: '⏸', cls: 'muted' },
|
||||||
const isCurrent = groupStates.includes(task.state) && !hasArtifact;
|
complete: { icon: '✓', cls: 'ok' },
|
||||||
const cls = isComplete ? 'complete' : isCurrent ? 'in_progress' : 'incomplete';
|
};
|
||||||
const icon = isComplete ? '✓' : isCurrent ? '◐' : '○';
|
const m = map[rt.status] || { icon: '○', cls: 'muted' };
|
||||||
return `<div class="timeline-phase ${cls}" title="${group.label}: ${cls}">${icon}</div>`;
|
return `<span class="bg-agent-status bg-status-${m.cls}">${m.icon} ${rt.status}</span>`;
|
||||||
}).join('');
|
}
|
||||||
const statusClass = task.state === 'done' ? 'done' : task.state === 'blocked' ? 'blocked' : 'in_progress';
|
|
||||||
const statusIcon = task.state === 'done' ? '✅' : task.state === 'blocked' ? '❌' : '🔄';
|
function _agentJobKey(job) {
|
||||||
const waveHtml = task.sub_tasks.length > 0
|
return (job.kind || 'unknown') + ':' + (job.name || 'default');
|
||||||
? `<div class="timeline-wave"><h5>Sub-tasks (${task.sub_tasks.filter(st => st.has_verdict).length}/${task.sub_tasks.length})</h5>
|
}
|
||||||
<div class="timeline-subtask-list">${task.sub_tasks.map(st => {
|
|
||||||
const stStatus = st.has_verdict && st.verdict_status === 'PASS' ? 'pass' : st.has_verdict && st.verdict_status === 'FAIL' ? 'fail' : 'incomplete';
|
function _renderAgentCard(meta, job) {
|
||||||
const stIcon = stStatus === 'pass' ? '✓' : stStatus === 'fail' ? '✗' : '○';
|
const key = _agentJobKey(job);
|
||||||
return `<div class="timeline-subtask ${stStatus}"><span>${stIcon}</span><span>${st.name}</span></div>`;
|
const name = job.name || meta.label;
|
||||||
}).join('')}</div></div>`
|
const interval = job.next_run_seconds
|
||||||
|
? `Every ${Math.round(job.next_run_seconds / 3600)}h` : '—';
|
||||||
|
const stub = job.stub_path
|
||||||
|
? `<div class="bg-row"><dt>stub</dt><dd><code>${job.stub_path}</code></dd></div>`
|
||||||
: '';
|
: '';
|
||||||
return `<div class="timeline-item">
|
const runtimeHtml = _renderRuntimeState(job.runtime_state);
|
||||||
<div class="timeline-item-header"><span class="timeline-item-name">${task.display_name}</span><span class="timeline-item-status">${statusIcon}</span></div>
|
const agentRuntime = _agentRuntimeIndicator(job.runtime_state);
|
||||||
<div class="timeline-phases">${phaseGroupHtml}</div>${waveHtml}</div>`;
|
const statusBorderCls = job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted';
|
||||||
|
return `<div class="bg-card bg-status-${statusBorderCls}" data-agent-card="${key}">
|
||||||
|
<div class="bg-card-header">
|
||||||
|
<div class="bg-card-title-row">
|
||||||
|
${meta.icon} <strong class="bg-name">${name}</strong>
|
||||||
|
<span data-agent-field="rt-indicator">${agentRuntime}</span>
|
||||||
|
<span data-agent-field="status-badge">${_scheduledStatusBadge(job.status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-body">
|
||||||
|
<div class="bg-row"><dt>schedule</dt><dd>${interval}</dd></div>
|
||||||
|
${stub}
|
||||||
|
</div>
|
||||||
|
<div data-agent-field="runtime-state">${runtimeHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderInactiveCard(meta) {
|
||||||
|
return `<div class="bg-card bg-status-inactive">
|
||||||
|
<div class="bg-card-header">
|
||||||
|
<div class="bg-card-title-row">
|
||||||
|
${meta.icon} <strong class="bg-name bg-name-inactive">${meta.label}</strong>
|
||||||
|
<span class="bg-status bg-status-muted">Not Installed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${meta.desc ? `<div class="bg-body"><div class="bg-row" style="color: var(--text-muted); font-size: 11px;">${meta.desc}</div></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _agentPollerTick() {
|
||||||
|
const data = await fetchSchedule();
|
||||||
|
if (!data || !data.jobs) return;
|
||||||
|
for (const job of data.jobs) {
|
||||||
|
const key = _agentJobKey(job);
|
||||||
|
const card = document.querySelector(`[data-agent-card="${key}"]`);
|
||||||
|
if (!card) continue;
|
||||||
|
// Update status badge text + class
|
||||||
|
const badge = card.querySelector('[data-agent-field="status-badge"] .bg-status');
|
||||||
|
if (badge) {
|
||||||
|
const map = { enabled: 'Enabled', disabled: 'Disabled', misconfigured: 'Misconfigured' };
|
||||||
|
badge.textContent = map[job.status] || job.status || 'Unknown';
|
||||||
|
badge.className = `bg-status bg-status-${
|
||||||
|
job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
// Update runtime indicator
|
||||||
|
const rtIndicator = card.querySelector('[data-agent-field="rt-indicator"]');
|
||||||
|
if (rtIndicator) {
|
||||||
|
rtIndicator.innerHTML = _agentRuntimeIndicator(job.runtime_state);
|
||||||
|
}
|
||||||
|
// Update runtime state section
|
||||||
|
const rtSection = card.querySelector('[data-agent-field="runtime-state"]');
|
||||||
|
if (rtSection) {
|
||||||
|
rtSection.innerHTML = _renderRuntimeState(job.runtime_state);
|
||||||
|
}
|
||||||
|
// Update card border
|
||||||
|
const borderCls = job.status === 'enabled' ? 'ok' : job.status === 'misconfigured' ? 'bad' : 'muted';
|
||||||
|
card.className = card.className.replace(/bg-status-\w+/g, `bg-status-${borderCls}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _agentPoller = null;
|
||||||
|
const AGENT_POLL_INTERVAL = 5000;
|
||||||
|
|
||||||
|
function _stopAgentPoller() {
|
||||||
|
if (_agentPoller) {
|
||||||
|
clearInterval(_agentPoller);
|
||||||
|
_agentPoller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startAgentPoller() {
|
||||||
|
_stopAgentPoller();
|
||||||
|
_agentPoller = setInterval(_agentPollerTick, AGENT_POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAgentTab() {
|
||||||
|
const panel = document.getElementById('agent-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
// If poller is already running, do a targeted update instead of full rebuild
|
||||||
|
if (_agentPoller) {
|
||||||
|
await _agentPollerTick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel.innerHTML = '<div class="bg-loading">Loading agents…</div>';
|
||||||
|
const data = await fetchSchedule();
|
||||||
|
const jobs = data.jobs || [];
|
||||||
|
if (data.available === false) {
|
||||||
|
panel.innerHTML = '<div class="bg-empty">No agents available (not running from ~/.automaton).</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.error) {
|
||||||
|
panel.innerHTML = `<div class="bg-error">Error: ${data.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Group jobs by agent type
|
||||||
|
const groups = {};
|
||||||
|
jobs.forEach(j => {
|
||||||
|
const key = _resolveAgentType(j);
|
||||||
|
(groups[key] = groups[key] || []).push(j);
|
||||||
|
});
|
||||||
|
// Render a card for each agent type — active instances or placeholder
|
||||||
|
const cardsHtml = AGENT_TYPES.map(key => {
|
||||||
|
const meta = AGENT_TYPE_META[key];
|
||||||
|
const typeJobs = groups[key];
|
||||||
|
if (!typeJobs || typeJobs.length === 0) {
|
||||||
|
return _renderInactiveCard(meta);
|
||||||
|
}
|
||||||
|
return typeJobs.map(job => _renderAgentCard(meta, job)).join('');
|
||||||
}).join('');
|
}).join('');
|
||||||
panel.innerHTML = `<div class="timeline-header"><h4>Phase Legend:</h4><div class="phase-legend">${legend}</div></div>${itemsHtml}`;
|
panel.innerHTML = `<div class="bg-grid">${cardsHtml}</div>`;
|
||||||
|
_startAgentPoller();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReview(taskName, status) {
|
async function submitReview(taskName, status) {
|
||||||
@@ -414,14 +559,19 @@ function switchView(view) {
|
|||||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||||
const target = document.getElementById(`view-${view}`);
|
const target = document.getElementById(`view-${view}`);
|
||||||
if (target) target.classList.add('active');
|
if (target) target.classList.add('active');
|
||||||
|
if (view === 'agent') {
|
||||||
|
renderAgentTab(); // full render on switch; starts poller
|
||||||
|
} else {
|
||||||
|
_stopAgentPoller();
|
||||||
renderCurrentView();
|
renderCurrentView();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCurrentView() {
|
function renderCurrentView() {
|
||||||
switch (state.currentView) {
|
switch (state.currentView) {
|
||||||
case 'board': renderBoard(); break;
|
case 'board': renderBoard(); break;
|
||||||
case 'stats': renderStats(); break;
|
case 'stats': renderStats(); break;
|
||||||
case 'timeline': renderTimeline(); break;
|
// Agent tab runs its own independent poller — not refreshed on global ticks
|
||||||
}
|
}
|
||||||
renderHeader();
|
renderHeader();
|
||||||
}
|
}
|
||||||
@@ -451,10 +601,10 @@ function attachCardListeners() {
|
|||||||
function setupKeyboard() {
|
function setupKeyboard() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') { closeDetail(); document.getElementById('help-modal').classList.remove('open'); document.getElementById('search-input').value = ''; state.searchQuery = ''; renderCurrentView(); }
|
if (e.key === 'Escape') { closeDetail(); document.getElementById('help-modal').classList.remove('open'); document.getElementById('search-input').value = ''; state.searchQuery = ''; renderCurrentView(); }
|
||||||
else if (e.key === ' ' && !e.target.matches('input, textarea, select')) { e.preventDefault(); const views = ['board', 'stats', 'timeline']; const idx = views.indexOf(state.currentView); switchView(views[(idx + 1) % views.length]); }
|
else if (e.key === ' ' && !e.target.matches('input, textarea, select')) { e.preventDefault(); const views = ['board', 'stats', 'agent']; const idx = views.indexOf(state.currentView); switchView(views[(idx + 1) % views.length]); }
|
||||||
else if (e.key === '1' && !e.target.matches('input, textarea, select')) { switchView('board'); }
|
else if (e.key === '1' && !e.target.matches('input, textarea, select')) { switchView('board'); }
|
||||||
else if (e.key === '2' && !e.target.matches('input, textarea, select')) { switchView('stats'); }
|
else if (e.key === '2' && !e.target.matches('input, textarea, select')) { switchView('stats'); }
|
||||||
else if (e.key === '3' && !e.target.matches('input, textarea, select')) { switchView('timeline'); }
|
else if (e.key === '3' && !e.target.matches('input, textarea, select')) { switchView('agent'); }
|
||||||
else if (e.key === 't' && !e.target.matches('input, textarea, select')) { switchTheme(); }
|
else if (e.key === 't' && !e.target.matches('input, textarea, select')) { switchTheme(); }
|
||||||
else if (e.key === 'r' && !e.target.matches('input, textarea, select')) { refreshData(); }
|
else if (e.key === 'r' && !e.target.matches('input, textarea, select')) { refreshData(); }
|
||||||
else if (e.key === 'f' && !e.target.matches('input, textarea, select')) { state.filterVisible = !state.filterVisible; document.getElementById('filter-bar').classList.toggle('visible', state.filterVisible); }
|
else if (e.key === 'f' && !e.target.matches('input, textarea, select')) { state.filterVisible = !state.filterVisible; document.getElementById('filter-bar').classList.toggle('visible', state.filterVisible); }
|
||||||
@@ -496,15 +646,12 @@ function applyConfig(config) {
|
|||||||
state.theme = config.theme;
|
state.theme = config.theme;
|
||||||
document.documentElement.setAttribute('data-theme', config.theme);
|
document.documentElement.setAttribute('data-theme', config.theme);
|
||||||
}
|
}
|
||||||
if (config.default_view && ['board', 'statistics', 'timeline'].includes(config.default_view)) {
|
if (config.default_view && ['board', 'statistics', 'agent'].includes(config.default_view)) {
|
||||||
switchView(config.default_view === 'statistics' ? 'stats' : config.default_view);
|
switchView(config.default_view === 'statistics' ? 'stats' : config.default_view);
|
||||||
}
|
}
|
||||||
if (config.column_width && config.column_width >= 10) {
|
if (config.column_width && config.column_width >= 10) {
|
||||||
document.documentElement.style.setProperty('--col-min-width', config.column_width + 'ch');
|
document.documentElement.style.setProperty('--col-min-width', config.column_width + 'ch');
|
||||||
}
|
}
|
||||||
if (config.show_timelines === false) {
|
|
||||||
document.querySelectorAll('.timeline-phase, .timeline-wave').forEach(el => el.style.display = 'none');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="view-tabs">
|
<div class="view-tabs">
|
||||||
<button class="tab active" data-view="board">Board</button>
|
<button class="tab active" data-view="board">Board</button>
|
||||||
<button class="tab" data-view="stats">Stats</button>
|
<button class="tab" data-view="stats">Stats</button>
|
||||||
<button class="tab" data-view="timeline">Timeline</button>
|
<button class="tab" data-view="agent">Agent</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -80,8 +80,8 @@
|
|||||||
<div class="view" id="view-stats">
|
<div class="view" id="view-stats">
|
||||||
<div class="stats-panel" id="stats-panel"></div>
|
<div class="stats-panel" id="stats-panel"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="view" id="view-timeline">
|
<div class="view" id="view-agent">
|
||||||
<div class="timeline-panel" id="timeline-panel"></div>
|
<div class="agent-panel" id="agent-panel"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -107,10 +107,10 @@
|
|||||||
<div class="help-modal-content">
|
<div class="help-modal-content">
|
||||||
<h2>Keyboard Shortcuts</h2>
|
<h2>Keyboard Shortcuts</h2>
|
||||||
<table class="help-table">
|
<table class="help-table">
|
||||||
<tr><td><kbd>Space</kbd></td><td>Cycle views (Board → Stats → Timeline)</td></tr>
|
<tr><td><kbd>Space</kbd></td><td>Cycle views (Board → Stats → Agent)</td></tr>
|
||||||
<tr><td><kbd>1</kbd></td><td>Board view</td></tr>
|
<tr><td><kbd>1</kbd></td><td>Board view</td></tr>
|
||||||
<tr><td><kbd>2</kbd></td><td>Statistics view</td></tr>
|
<tr><td><kbd>2</kbd></td><td>Statistics view</td></tr>
|
||||||
<tr><td><kbd>3</kbd></td><td>Timeline view</td></tr>
|
<tr><td><kbd>3</kbd></td><td>Agent view</td></tr>
|
||||||
<tr><td><kbd>t</kbd></td><td>Cycle themes (default → dark → light)</td></tr>
|
<tr><td><kbd>t</kbd></td><td>Cycle themes (default → dark → light)</td></tr>
|
||||||
<tr><td><kbd>r</kbd></td><td>Manual refresh</td></tr>
|
<tr><td><kbd>r</kbd></td><td>Manual refresh</td></tr>
|
||||||
<tr><td><kbd>f</kbd></td><td>Toggle filter bar</td></tr>
|
<tr><td><kbd>f</kbd></td><td>Toggle filter bar</td></tr>
|
||||||
|
|||||||
@@ -256,37 +256,36 @@ body {
|
|||||||
.bar-track { flex: 1; height: 20px; background: var(--bg-primary); border-radius: 6px; overflow: hidden; }
|
.bar-track { flex: 1; height: 20px; background: var(--bg-primary); border-radius: 6px; overflow: hidden; }
|
||||||
.bar-fill { height: 100%; border-radius: 6px; transition: width 0.4s ease; }
|
.bar-fill { height: 100%; border-radius: 6px; transition: width 0.4s ease; }
|
||||||
.bar-count { width: 36px; font-size: 12px; color: var(--text-secondary); text-align: right; flex-shrink: 0; font-weight: 600; }
|
.bar-count { width: 36px; font-size: 12px; color: var(--text-secondary); text-align: right; flex-shrink: 0; font-weight: 600; }
|
||||||
.timeline-panel { max-width: 900px; margin: 0 auto; }
|
.agent-panel { max-width: 1100px; margin: 0 auto; }
|
||||||
.timeline-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
.bg-loading, .bg-empty, .bg-error { padding: 24px; text-align: center; color: var(--text-secondary); font-size: 13px; }
|
||||||
.phase-legend { display: flex; align-items: center; gap: 10px; }
|
.bg-error { color: var(--error); }
|
||||||
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-secondary); }
|
.bg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
||||||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
.bg-card {
|
||||||
.legend-dot.complete { background: var(--success); }
|
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md);
|
||||||
.legend-dot.in_progress { background: var(--warning); }
|
padding: 14px 16px; box-shadow: var(--elevation-1);
|
||||||
.legend-dot.incomplete { background: var(--text-muted); }
|
|
||||||
.timeline-item { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 14px 18px; margin-bottom: 10px; }
|
|
||||||
.timeline-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
|
||||||
.timeline-item-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
|
||||||
.timeline-item-status { font-size: 13px; }
|
|
||||||
.timeline-phases { display: flex; gap: 4px; }
|
|
||||||
.timeline-phase {
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 11px; font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.timeline-phase.complete { background: var(--success); color: white; }
|
.bg-card.bg-status-ok { border-left: 3px solid var(--success); }
|
||||||
.timeline-phase.in_progress { background: var(--warning); color: var(--bg-primary); }
|
.bg-card.bg-status-muted { border-left: 3px solid var(--text-muted); }
|
||||||
.timeline-phase.incomplete { background: var(--bg-primary); color: var(--text-muted); border: 1px solid var(--border-color); }
|
.bg-card.bg-status-bad { border-left: 3px solid var(--error); }
|
||||||
.timeline-wave { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-color); }
|
.bg-card.bg-status-inactive { border-left: 3px solid var(--text-muted); opacity: 0.55; }
|
||||||
.timeline-wave h5 { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
|
.bg-card-header { margin-bottom: 10px; }
|
||||||
.timeline-subtask-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
.bg-card-title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
.timeline-subtask {
|
.bg-name { font-size: 14px; color: var(--text-primary); }
|
||||||
display: flex; align-items: center; gap: 4px; padding: 3px 10px;
|
.bg-name-inactive { color: var(--text-muted); }
|
||||||
background: var(--bg-primary); border-radius: var(--radius-sm); font-size: 11px; color: var(--text-secondary);
|
.bg-agent-status { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
}
|
.bg-status { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
.timeline-subtask.pass { border-left: 2px solid var(--success); }
|
.bg-status-ok { background: var(--success-bg); color: var(--success); }
|
||||||
.timeline-subtask.fail { border-left: 2px solid var(--error); }
|
.bg-status-muted { background: rgba(112,117,122,0.15); color: var(--text-muted); }
|
||||||
.timeline-subtask.incomplete { border-left: 2px solid var(--text-muted); }
|
.bg-status-bad { background: var(--error-bg); color: var(--error); }
|
||||||
|
.bg-body { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.bg-row { display: flex; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.bg-row dt { color: var(--text-muted); min-width: 60px; font-weight: 500; }
|
||||||
|
.bg-row dd { color: var(--text-primary); word-break: break-all; }
|
||||||
|
.bg-runtime { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border-color); }
|
||||||
|
.bg-runtime h5 { font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
|
||||||
|
.bg-runtime dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; }
|
||||||
|
.bg-runtime dt { font-size: 11px; color: var(--text-muted); font-weight: 500; }
|
||||||
|
.bg-runtime dd { font-size: 11px; color: var(--text-primary); word-break: break-all; }
|
||||||
.footer { display: flex; justify-content: space-between; align-items: center; padding: 8px 24px; background: var(--bg-secondary); border-top: 1px solid var(--border-color); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
|
.footer { display: flex; justify-content: space-between; align-items: center; padding: 8px 24px; background: var(--bg-secondary); border-top: 1px solid var(--border-color); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
|
||||||
.help-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
|
.help-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
|
||||||
.help-modal.open { display: flex; animation: fadeIn 0.15s ease; }
|
.help-modal.open { display: flex; animation: fadeIn 0.15s ease; }
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ from ..core.scope import detect_scope
|
|||||||
from ..core.task import discover_tasks, TaskState, COLUMN_HEADERS
|
from ..core.task import discover_tasks, TaskState, COLUMN_HEADERS
|
||||||
from ..config import DashboardConfig, get_config_path
|
from ..config import DashboardConfig, get_config_path
|
||||||
|
|
||||||
|
_status_module = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status_module():
|
||||||
|
"""Lazily import scripts/status.py so the dashboard can call
|
||||||
|
`_list_scheduled_jobs` without adding scripts/ to sys.path permanently."""
|
||||||
|
global _status_module
|
||||||
|
if _status_module is None:
|
||||||
|
import importlib.util
|
||||||
|
status_path = Path.home() / ".automaton" / "scripts" / "status.py"
|
||||||
|
if not status_path.exists():
|
||||||
|
return None
|
||||||
|
spec = importlib.util.spec_from_file_location("automaton_status", status_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
_status_module = mod
|
||||||
|
return _status_module
|
||||||
|
|
||||||
# Task states for API responses - maps state names to artifact names
|
# Task states for API responses - maps state names to artifact names
|
||||||
TASK_STATE_ARTIFACT = {
|
TASK_STATE_ARTIFACT = {
|
||||||
"research": "SPEC.md",
|
"research": "SPEC.md",
|
||||||
@@ -86,6 +104,8 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
|||||||
self._serve_task(task_name)
|
self._serve_task(task_name)
|
||||||
elif self.path == "/api/review-summary":
|
elif self.path == "/api/review-summary":
|
||||||
self._serve_review_summary()
|
self._serve_review_summary()
|
||||||
|
elif self.path == "/api/scheduled":
|
||||||
|
self._serve_scheduled()
|
||||||
else:
|
else:
|
||||||
self._serve_static()
|
self._serve_static()
|
||||||
|
|
||||||
@@ -383,6 +403,24 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
|||||||
counts["pending"] += 1
|
counts["pending"] += 1
|
||||||
self._send_json(counts)
|
self._send_json(counts)
|
||||||
|
|
||||||
|
def _serve_scheduled(self):
|
||||||
|
"""Serve the list of installed scheduled jobs (loop ticks + cleanup)."""
|
||||||
|
status_mod = _get_status_module()
|
||||||
|
if status_mod is None:
|
||||||
|
self._send_json({"jobs": [], "available": False})
|
||||||
|
return
|
||||||
|
list_fn = getattr(status_mod, "_list_scheduled_jobs", None)
|
||||||
|
if list_fn is None:
|
||||||
|
self._send_json({"jobs": [], "available": False})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
project_arg = str(self.project_root) if self.project_root else None
|
||||||
|
jobs = list_fn(project=project_arg)
|
||||||
|
except Exception as exc:
|
||||||
|
self._send_json({"jobs": [], "available": True, "error": str(exc)})
|
||||||
|
return
|
||||||
|
self._send_json({"jobs": jobs, "available": True})
|
||||||
|
|
||||||
def _send_json(self, data):
|
def _send_json(self, data):
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
python3 "/Users/laptran/.automaton/scripts/status.py" --cleanup-done --days 7
|
||||||
+317
-15
@@ -42,6 +42,8 @@ Usage:
|
|||||||
python status.py --available --agent {id} List available work
|
python status.py --available --agent {id} List available work
|
||||||
python status.py --can-edit --project {p} [--task {name}] [--file {path}] Check if edits allowed (harness hook)
|
python status.py --can-edit --project {p} [--task {name}] [--file {path}] Check if edits allowed (harness hook)
|
||||||
python status.py --scope-check --task {name} --file {path} Check file scope
|
python status.py --scope-check --task {name} --file {path} Check file scope
|
||||||
|
python status.py --cleanup-done [--days 7] [--dry-run] Archive complete tasks older than N days into tasks/complete/
|
||||||
|
python status.py --install-cleanup-schedule [--days 7] [--interval 86400] Install daily cleanup job
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -262,7 +264,7 @@ def _all_task_dirs(project: Optional[str] = None) -> list[tuple[str, Path]]:
|
|||||||
if not base.exists():
|
if not base.exists():
|
||||||
return tasks
|
return tasks
|
||||||
for entry in sorted(base.iterdir()):
|
for entry in sorted(base.iterdir()):
|
||||||
if entry.is_dir() and not entry.name.startswith("."):
|
if entry.is_dir() and not entry.name.startswith(".") and entry.name != "complete":
|
||||||
tasks.append((entry.name, entry))
|
tasks.append((entry.name, entry))
|
||||||
subtasks = entry / "subtasks"
|
subtasks = entry / "subtasks"
|
||||||
if subtasks.exists():
|
if subtasks.exists():
|
||||||
@@ -1625,6 +1627,44 @@ LOOP_TICK_SCRIPT_SH = "automaton-loop-tick.sh"
|
|||||||
LOOP_TICK_SCRIPT_BAT = "automaton-loop-tick.bat"
|
LOOP_TICK_SCRIPT_BAT = "automaton-loop-tick.bat"
|
||||||
LOOP_CONTEXT_FLOOR_KB = 16_000 # mirrors vram_detect.LOOP_MODE_CONTEXT_FLOOR_KB
|
LOOP_CONTEXT_FLOOR_KB = 16_000 # mirrors vram_detect.LOOP_MODE_CONTEXT_FLOOR_KB
|
||||||
|
|
||||||
|
CLEANUP_DEFAULT_DAYS = 7
|
||||||
|
CLEANUP_DEFAULT_INTERVAL_SEC = 86400 # daily
|
||||||
|
CLEANUP_TICK_SCRIPT_SH = "automaton-cleanup.sh"
|
||||||
|
CLEANUP_TICK_SCRIPT_BAT = "automaton-cleanup.bat"
|
||||||
|
CLEANUP_LABEL = "cleanup" # launchd label: com.automaton.cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def _completed_task_age_days(task_path: Path) -> Optional[float]:
|
||||||
|
"""Days since the task last transitioned to its current phase, measured by
|
||||||
|
the ``.state`` file mtime (the moment ``_write_state`` recorded completion).
|
||||||
|
Returns None if ``.state`` is missing or unreadable."""
|
||||||
|
state_file = task_path / ".state"
|
||||||
|
if not state_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
mtime = state_file.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (datetime.now(timezone.utc).timestamp() - mtime) / 86400.0
|
||||||
|
|
||||||
|
|
||||||
|
def _launchagents_dir() -> Path:
|
||||||
|
"""LaunchAgents directory, overridable via AUTOMATON_LAUNCHAGENTS_DIR so
|
||||||
|
tests can redirect plist writes to a temp dir without touching the host."""
|
||||||
|
override = os.environ.get("AUTOMATON_LAUNCHAGENTS_DIR")
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return Path.home() / "Library" / "LaunchAgents"
|
||||||
|
|
||||||
|
|
||||||
|
def _tasks_root_for(task_path: Path) -> Path:
|
||||||
|
"""Return the tasks directory that should hold the ``complete/`` archive
|
||||||
|
for this task (handles subtask paths)."""
|
||||||
|
parent = task_path.parent
|
||||||
|
if parent.name == "complete":
|
||||||
|
return parent.parent
|
||||||
|
return parent
|
||||||
|
|
||||||
|
|
||||||
def _loops_dir(project: Optional[str] = None) -> Path:
|
def _loops_dir(project: Optional[str] = None) -> Path:
|
||||||
project_dir = _find_project_dir(project)
|
project_dir = _find_project_dir(project)
|
||||||
@@ -1850,7 +1890,7 @@ def _disable_schedule(name: str, project: Optional[str]) -> None:
|
|||||||
loop_path = _loop_dir(name, project)
|
loop_path = _loop_dir(name, project)
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Darwin":
|
if system == "Darwin":
|
||||||
plist = Path.home() / "Library" / "LaunchAgents" / f"com.automaton.loop.{name}.plist"
|
plist = _launchagents_dir() / f"com.automaton.loop.{name}.plist"
|
||||||
if plist.exists():
|
if plist.exists():
|
||||||
plist.rename(plist.with_suffix(".plist.disabled"))
|
plist.rename(plist.with_suffix(".plist.disabled"))
|
||||||
try:
|
try:
|
||||||
@@ -1883,14 +1923,11 @@ def _disable_schedule(name: str, project: Optional[str]) -> None:
|
|||||||
capture_output=True, timeout=5, check=False)
|
capture_output=True, timeout=5, check=False)
|
||||||
|
|
||||||
|
|
||||||
def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
|
def _install_cron_block_generic(marker: str, stub_path: Path, interval: int) -> int:
|
||||||
"""Install (or re-install) the cron block for a loop. Idempotent: strips
|
"""Install (or re-install) an idempotent cron block delimited by
|
||||||
any prior block for the same loop name before appending the new one.
|
``# <marker>`` / ``# end <marker>``. Strips any prior block with the same
|
||||||
|
marker before appending the new one. Returns 0 on success, 2 on failure."""
|
||||||
Returns 0 on success, 2 on crontab write failure.
|
|
||||||
"""
|
|
||||||
minutes_interval = max(1, interval // 60)
|
minutes_interval = max(1, interval // 60)
|
||||||
stub_path = loop_path / LOOP_TICK_SCRIPT_SH
|
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
|
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
|
||||||
timeout=5, check=False)
|
timeout=5, check=False)
|
||||||
@@ -1900,17 +1937,17 @@ def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
|
|||||||
kept = []
|
kept = []
|
||||||
inside = False
|
inside = False
|
||||||
for line in existing:
|
for line in existing:
|
||||||
if line.strip() == f"# automaton-loop:{name}":
|
if line.strip() == f"# {marker}":
|
||||||
inside = True
|
inside = True
|
||||||
continue
|
continue
|
||||||
if inside and line.strip() == f"# end automaton-loop:{name}":
|
if inside and line.strip() == f"# end {marker}":
|
||||||
inside = False
|
inside = False
|
||||||
continue
|
continue
|
||||||
if not inside:
|
if not inside:
|
||||||
kept.append(line)
|
kept.append(line)
|
||||||
kept.append(f"# automaton-loop:{name}")
|
kept.append(f"# {marker}")
|
||||||
kept.append(f"*/{minutes_interval} * * * * {stub_path}")
|
kept.append(f"*/{minutes_interval} * * * * {stub_path}")
|
||||||
kept.append(f"# end automaton-loop:{name}")
|
kept.append(f"# end {marker}")
|
||||||
try:
|
try:
|
||||||
subprocess.run(["crontab", "-"], input="\n".join(kept) + "\n",
|
subprocess.run(["crontab", "-"], input="\n".join(kept) + "\n",
|
||||||
capture_output=True, text=True, timeout=5, check=False)
|
capture_output=True, text=True, timeout=5, check=False)
|
||||||
@@ -1920,12 +1957,22 @@ def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
|
||||||
|
"""Install (or re-install) the cron block for a loop. Idempotent: strips
|
||||||
|
any prior block for the same loop name before appending the new one.
|
||||||
|
|
||||||
|
Returns 0 on success, 2 on crontab write failure.
|
||||||
|
"""
|
||||||
|
stub_path = loop_path / LOOP_TICK_SCRIPT_SH
|
||||||
|
return _install_cron_block_generic(f"automaton-loop:{name}", stub_path, interval)
|
||||||
|
|
||||||
|
|
||||||
def _enable_schedule(name: str, project: Optional[str]) -> None:
|
def _enable_schedule(name: str, project: Optional[str]) -> None:
|
||||||
"""Inverse of _disable_schedule. Called from --resume-loop after pause clear."""
|
"""Inverse of _disable_schedule. Called from --resume-loop after pause clear."""
|
||||||
loop_path = _loop_dir(name, project)
|
loop_path = _loop_dir(name, project)
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Darwin":
|
if system == "Darwin":
|
||||||
plist = Path.home() / "Library" / "LaunchAgents" / f"com.automaton.loop.{name}.plist"
|
plist = _launchagents_dir() / f"com.automaton.loop.{name}.plist"
|
||||||
disabled = plist.with_suffix(".plist.disabled")
|
disabled = plist.with_suffix(".plist.disabled")
|
||||||
if disabled.exists() and not plist.exists():
|
if disabled.exists() and not plist.exists():
|
||||||
disabled.rename(plist)
|
disabled.rename(plist)
|
||||||
@@ -2028,7 +2075,7 @@ def cmd_install_schedule(args) -> int:
|
|||||||
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Darwin":
|
if system == "Darwin":
|
||||||
plist_dir = Path.home() / "Library" / "LaunchAgents"
|
plist_dir = _launchagents_dir()
|
||||||
plist_dir.mkdir(parents=True, exist_ok=True)
|
plist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
plist_path = plist_dir / f"com.automaton.loop.{name}.plist"
|
plist_path = plist_dir / f"com.automaton.loop.{name}.plist"
|
||||||
plist = (
|
plist = (
|
||||||
@@ -2072,6 +2119,251 @@ def cmd_install_schedule(args) -> int:
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_cleanup_done(args) -> int:
|
||||||
|
"""--cleanup-done [--days N] [--dry-run] [--json]
|
||||||
|
|
||||||
|
Move tasks whose ``.state`` base phase is ``complete`` and whose completion
|
||||||
|
age (measured by ``.state`` mtime) is >= --days into the ``tasks/complete/``
|
||||||
|
archive folder. Archived tasks are excluded from ``--list``/``--audit`` and
|
||||||
|
from the dashboard. Idempotent. ``--dry-run`` lists candidates without moving.
|
||||||
|
"""
|
||||||
|
days = args.days if args.days is not None else CLEANUP_DEFAULT_DAYS
|
||||||
|
if days < 0:
|
||||||
|
print("ERROR: --days must be non-negative")
|
||||||
|
return 2
|
||||||
|
tasks = _all_task_dirs(args.project)
|
||||||
|
moved = []
|
||||||
|
skipped = []
|
||||||
|
candidates = []
|
||||||
|
for name, path in tasks:
|
||||||
|
phase = _read_state(path)
|
||||||
|
if phase is None:
|
||||||
|
continue
|
||||||
|
if _base_phase(phase) != "complete":
|
||||||
|
continue
|
||||||
|
age = _completed_task_age_days(path)
|
||||||
|
if age is None:
|
||||||
|
continue
|
||||||
|
if age < days:
|
||||||
|
continue
|
||||||
|
candidates.append((name, path, age))
|
||||||
|
tasks_root = _tasks_root_for(path)
|
||||||
|
complete_dir = tasks_root / "complete"
|
||||||
|
dest = complete_dir / name
|
||||||
|
if dest.exists():
|
||||||
|
skipped.append({"name": name, "reason": "dest_exists", "age_days": round(age, 2)})
|
||||||
|
continue
|
||||||
|
if args.dry_run:
|
||||||
|
moved.append({"name": name, "dest": str(dest), "age_days": round(age, 2),
|
||||||
|
"dry_run": True})
|
||||||
|
continue
|
||||||
|
complete_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
path.rename(dest)
|
||||||
|
except OSError as exc:
|
||||||
|
skipped.append({"name": name, "reason": f"rename_failed: {exc}",
|
||||||
|
"age_days": round(age, 2)})
|
||||||
|
continue
|
||||||
|
moved.append({"name": name, "dest": str(dest), "age_days": round(age, 2)})
|
||||||
|
|
||||||
|
verb = "would move" if args.dry_run else "moved"
|
||||||
|
for item in moved:
|
||||||
|
print(f"[{verb}] {item['name']} (age {item['age_days']}d) -> {item['dest']}")
|
||||||
|
for item in skipped:
|
||||||
|
print(f"[skipped] {item['name']} ({item['reason']}, age {item['age_days']}d)")
|
||||||
|
if args.json_output:
|
||||||
|
summary = {
|
||||||
|
"days": days, "dry_run": bool(args.dry_run),
|
||||||
|
"moved": [m["name"] for m in moved],
|
||||||
|
"skipped": skipped, "candidates": [c[0] for c in candidates],
|
||||||
|
}
|
||||||
|
print(json.dumps(summary))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_stub_path() -> Path:
|
||||||
|
return AUTOMATON_DIR / "scripts" / "automaton-cleanup.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_install_cleanup_schedule(args) -> int:
|
||||||
|
"""--install-cleanup-schedule [--days N] [--interval SEC]
|
||||||
|
|
||||||
|
Install an OS scheduler unit that runs ``--cleanup-done`` periodically
|
||||||
|
(default daily). Mirrors ``--install-schedule`` but for the cleanup job,
|
||||||
|
which is framework-wide (project = the framework dir)."""
|
||||||
|
days = args.days if args.days is not None else CLEANUP_DEFAULT_DAYS
|
||||||
|
interval = args.interval if args.interval is not None else CLEANUP_DEFAULT_INTERVAL_SEC
|
||||||
|
project_dir = _find_project_dir(args.project)
|
||||||
|
status_script = AUTOMATON_DIR / "scripts" / "status.py"
|
||||||
|
project_arg = f"--project \"{project_dir}\"" if str(project_dir) != str(AUTOMATON_DIR) else ""
|
||||||
|
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
stub_path = AUTOMATON_DIR / "scripts" / "automaton-cleanup.bat"
|
||||||
|
stub = (
|
||||||
|
"@echo off\r\n"
|
||||||
|
f"python \"{status_script}\" --cleanup-done --days {days} {project_arg}\r\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stub_path = _cleanup_stub_path()
|
||||||
|
stub = (
|
||||||
|
"#!/usr/bin/env bash\n"
|
||||||
|
f"python3 \"{status_script}\" --cleanup-done --days {days}"
|
||||||
|
+ (f" {project_arg}\n" if project_arg else "\n")
|
||||||
|
)
|
||||||
|
stub_path.write_text(stub)
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
stub_path.chmod(0o755)
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Darwin":
|
||||||
|
plist_dir = _launchagents_dir()
|
||||||
|
plist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
plist_path = plist_dir / f"com.automaton.cleanup.plist"
|
||||||
|
plist = (
|
||||||
|
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||||
|
f"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
|
||||||
|
f"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
|
||||||
|
f"<plist version=\"1.0\">\n"
|
||||||
|
f"<dict>\n"
|
||||||
|
f" <key>Label</key><string>com.automaton.cleanup</string>\n"
|
||||||
|
f" <key>ProgramArguments</key>\n"
|
||||||
|
f" <array>\n"
|
||||||
|
f" <string>{stub_path}</string>\n"
|
||||||
|
f" </array>\n"
|
||||||
|
f" <key>StartInterval</key><integer>{interval}</integer>\n"
|
||||||
|
f" <key>RunAtLoad</key><false/>\n"
|
||||||
|
f"</dict>\n"
|
||||||
|
f"</plist>\n"
|
||||||
|
)
|
||||||
|
plist_path.write_text(plist)
|
||||||
|
print(f"Installed launchd unit: {plist_path}")
|
||||||
|
print(f"Interval: {interval}s, cleanup threshold: {days}d. Stub: {stub_path}")
|
||||||
|
return 0
|
||||||
|
elif system == "Linux":
|
||||||
|
rc = _install_cron_block_generic("automaton-cleanup", stub_path, interval)
|
||||||
|
if rc == 0:
|
||||||
|
print(f"Installed crontab block (every {max(1, interval // 60)} min), "
|
||||||
|
f"threshold {days}d. Stub: {stub_path}")
|
||||||
|
return rc
|
||||||
|
elif system == "Windows":
|
||||||
|
minutes_interval = max(1, interval // 60)
|
||||||
|
subprocess.run(
|
||||||
|
["schtasks", "/create", "/tn", "AutomatonCleanup",
|
||||||
|
"/tr", str(stub_path), "/sc", "minute",
|
||||||
|
"/mo", str(minutes_interval), "/f"],
|
||||||
|
capture_output=True, timeout=10, check=False,
|
||||||
|
)
|
||||||
|
print(f"Installed schtasks unit (every {minutes_interval} min). Stub: {stub_path}")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print(f"ERROR: unsupported platform '{system}' for --install-cleanup-schedule")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_launchd_plist(plist_path: Path) -> dict:
|
||||||
|
"""Minimal plist extractor for ProgramArguments, StartInterval, Label."""
|
||||||
|
import plistlib
|
||||||
|
try:
|
||||||
|
with plist_path.open("rb") as fh:
|
||||||
|
data = plistlib.load(fh)
|
||||||
|
return data
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_loop_runtime_state(name: str, project: Optional[str]) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
loop_path = _loop_dir(name, project)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return _read_state_loop(loop_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_scheduled_jobs(project: Optional[str] = None) -> list[dict]:
|
||||||
|
"""Enumerate installed scheduled jobs (loops + cleanup) for the dashboard
|
||||||
|
'Background' tab. Each entry: ``{label, kind, status, next_run_seconds,
|
||||||
|
stub_path, runtime_state?}``. ``status`` ∈ {enabled, disabled, misconfigured}."""
|
||||||
|
jobs: list[dict] = []
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
def _launchd_label_kind(label: str) -> tuple[str, str]:
|
||||||
|
# returns (kind, name_or_empty)
|
||||||
|
if label.startswith("com.automaton.loop."):
|
||||||
|
return ("loop", label[len("com.automaton.loop."):])
|
||||||
|
if label in ("com.automaton.cleanup",):
|
||||||
|
return ("cleanup", "")
|
||||||
|
if label.startswith("com.automaton.cleanup."):
|
||||||
|
return ("cleanup", label[len("com.automaton.cleanup."):])
|
||||||
|
return ("unknown", label)
|
||||||
|
|
||||||
|
if system == "Darwin" or system == "Windows":
|
||||||
|
# On Windows we cannot easily enumerate schtasks entries here; on Darwin
|
||||||
|
# we scan LaunchAgents. (Windows listing is best-effort left to the
|
||||||
|
# dashboard surfacing 'unknown' for the tab.)
|
||||||
|
if system == "Darwin":
|
||||||
|
plist_dir = _launchagents_dir()
|
||||||
|
if plist_dir.exists():
|
||||||
|
for entry in sorted(plist_dir.iterdir()):
|
||||||
|
if not entry.name.startswith("com.automaton."):
|
||||||
|
continue
|
||||||
|
disabled = entry.name.endswith(".plist.disabled")
|
||||||
|
real = entry.with_suffix("") if disabled else entry
|
||||||
|
label = real.stem # filename without .plist
|
||||||
|
data = _parse_launchd_plist(real) if real.exists() else {}
|
||||||
|
interval = data.get("StartInterval")
|
||||||
|
stub = (data.get("ProgramArguments") or [None])[0]
|
||||||
|
kind, sub = _launchd_label_kind(label)
|
||||||
|
runtime = None
|
||||||
|
agent_type = None
|
||||||
|
if kind == "loop" and sub:
|
||||||
|
s = _read_loop_runtime_state(sub, project)
|
||||||
|
runtime = s
|
||||||
|
loop_dir = _loop_dir(sub, project)
|
||||||
|
cfg = _read_loop_config(loop_dir) or {}
|
||||||
|
ws = cfg.get("work_source") or {}
|
||||||
|
agent_type = ws.get("kind", "single")
|
||||||
|
status = "misconfigured" if not real.exists() else (
|
||||||
|
"disabled" if disabled else "enabled")
|
||||||
|
# flag stub pointing at a missing path
|
||||||
|
if stub and not Path(stub).exists():
|
||||||
|
status = "misconfigured"
|
||||||
|
jobs.append({
|
||||||
|
"label": label, "kind": kind, "name": sub,
|
||||||
|
"status": status, "next_run_seconds": interval,
|
||||||
|
"stub_path": stub, "runtime_state": runtime,
|
||||||
|
"agent_type": agent_type,
|
||||||
|
})
|
||||||
|
if system == "Linux":
|
||||||
|
try:
|
||||||
|
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
|
||||||
|
timeout=5, check=False)
|
||||||
|
lines = res.stdout.splitlines() if res.returncode == 0 else []
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
lines = []
|
||||||
|
marker = None
|
||||||
|
for line in lines:
|
||||||
|
s = line.strip()
|
||||||
|
if s.startswith("# automaton-loop:") or s.startswith("#automaton-loop:"):
|
||||||
|
marker = s.lstrip("#").strip()
|
||||||
|
name = marker.split("automaton-loop:")[1]
|
||||||
|
cfg = _read_loop_config(_loop_dir(name, project)) or {}
|
||||||
|
ws = cfg.get("work_source") or {}
|
||||||
|
agent_type = ws.get("kind", "single")
|
||||||
|
jobs.append({
|
||||||
|
"label": f"automaton-loop:{name}", "kind": "loop", "name": name,
|
||||||
|
"status": "enabled", "next_run_seconds": None,
|
||||||
|
"stub_path": None, "runtime_state": _read_loop_runtime_state(name, project),
|
||||||
|
"agent_type": agent_type,
|
||||||
|
})
|
||||||
|
elif s in ("# automaton-cleanup", "#automaton-cleanup"):
|
||||||
|
jobs.append({
|
||||||
|
"label": "automaton-cleanup", "kind": "cleanup", "name": "",
|
||||||
|
"status": "enabled", "next_run_seconds": None,
|
||||||
|
"stub_path": None, "runtime_state": None,
|
||||||
|
})
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def cmd_pause_loop(args) -> int:
|
def cmd_pause_loop(args) -> int:
|
||||||
name = args.pause_loop
|
name = args.pause_loop
|
||||||
loop_path = _loop_dir(name, args.project)
|
loop_path = _loop_dir(name, args.project)
|
||||||
@@ -2565,6 +2857,12 @@ def main():
|
|||||||
parser.add_argument("--can-continue", metavar="NAME", help="Cheap status check: is the loop runnable right now?")
|
parser.add_argument("--can-continue", metavar="NAME", help="Cheap status check: is the loop runnable right now?")
|
||||||
parser.add_argument("--loop-list", action="store_true", help="List all loops and their status")
|
parser.add_argument("--loop-list", action="store_true", help="List all loops and their status")
|
||||||
parser.add_argument("--claim-loop-task", metavar="NAME", help="Claim a task for this loop (cross-loop ownership check)")
|
parser.add_argument("--claim-loop-task", metavar="NAME", help="Claim a task for this loop (cross-loop ownership check)")
|
||||||
|
parser.add_argument("--cleanup-done", action="store_true",
|
||||||
|
help="Move complete tasks older than --days into tasks/complete/ (age by .state mtime)")
|
||||||
|
parser.add_argument("--install-cleanup-schedule", action="store_true",
|
||||||
|
help="Install an OS scheduler unit that runs --cleanup-done periodically (default daily)")
|
||||||
|
parser.add_argument("--days", type=int, help="Cleanup age threshold in days (default 7, used with --cleanup-done / --install-cleanup-schedule)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="With --cleanup-done, list candidates without moving them")
|
||||||
parser.add_argument("--version", action="store_true", help="Print framework version and exit")
|
parser.add_argument("--version", action="store_true", help="Print framework version and exit")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -2587,6 +2885,10 @@ def main():
|
|||||||
return cmd_can_continue(args)
|
return cmd_can_continue(args)
|
||||||
if args.loop_list:
|
if args.loop_list:
|
||||||
return cmd_loop_list(args)
|
return cmd_loop_list(args)
|
||||||
|
if args.install_cleanup_schedule:
|
||||||
|
return cmd_install_cleanup_schedule(args)
|
||||||
|
if args.cleanup_done:
|
||||||
|
return cmd_cleanup_done(args)
|
||||||
if args.create_task:
|
if args.create_task:
|
||||||
return cmd_create_task(args)
|
return cmd_create_task(args)
|
||||||
if args.approve:
|
if args.approve:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user