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
|
||||
- Prefer writing to a temporary file and renaming it into place
|
||||
- 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",
|
||||
}
|
||||
|
||||
VALID_VIEWS = {"board", "statistics", "timeline"}
|
||||
VALID_VIEWS = {"board", "statistics", "timeline", "agent"}
|
||||
VALID_THEMES = {"default", "dark", "light"}
|
||||
|
||||
|
||||
|
||||
@@ -648,6 +648,8 @@ def discover_tasks(tasks_dir: Path) -> list[Task]:
|
||||
continue
|
||||
if folder_path.name == "subtasks":
|
||||
continue
|
||||
if folder_path.name == "complete":
|
||||
continue
|
||||
if not _VALID_TASK_NAME_CHARS.issuperset(set(folder_path.name)):
|
||||
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>` : ''}`;
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const panel = document.getElementById('timeline-panel');
|
||||
const filtered = getFilteredTasks();
|
||||
const AGENT_TYPE_META = {
|
||||
completed_task_archiver: { icon: '🧹', label: 'Completed Task Archiver', color: '#9aa0a6', desc: 'Archives completed tasks older than a threshold' },
|
||||
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 phaseGroupStates = {};
|
||||
PHASE_GROUPS.forEach(group => {
|
||||
phaseGroupStates[group.id] = {
|
||||
states: group.states,
|
||||
label: group.label,
|
||||
color: group.color,
|
||||
};
|
||||
const AGENT_TYPES = Object.keys(AGENT_TYPE_META);
|
||||
const AGENT_TYPE_META_FALLBACK = { icon: '🤖', label: 'Agent', color: '#8ab4f8', desc: '' };
|
||||
|
||||
async function fetchSchedule() {
|
||||
try {
|
||||
const res = await fetch('/api/scheduled');
|
||||
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>`;
|
||||
}
|
||||
|
||||
function _renderRuntimeState(rt) {
|
||||
if (!rt) return '';
|
||||
const rows = [];
|
||||
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>`;
|
||||
}
|
||||
|
||||
function _agentRuntimeIndicator(rt) {
|
||||
if (!rt || !rt.status) return '';
|
||||
const map = {
|
||||
running: { icon: '▶', cls: 'ok' },
|
||||
halted: { icon: '⏹', cls: 'bad' },
|
||||
paused: { icon: '⏸', cls: 'muted' },
|
||||
complete: { icon: '✓', cls: 'ok' },
|
||||
};
|
||||
const m = map[rt.status] || { icon: '○', cls: 'muted' };
|
||||
return `<span class="bg-agent-status bg-status-${m.cls}">${m.icon} ${rt.status}</span>`;
|
||||
}
|
||||
|
||||
function _agentJobKey(job) {
|
||||
return (job.kind || 'unknown') + ':' + (job.name || 'default');
|
||||
}
|
||||
|
||||
function _renderAgentCard(meta, job) {
|
||||
const key = _agentJobKey(job);
|
||||
const name = job.name || meta.label;
|
||||
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>`
|
||||
: '';
|
||||
const runtimeHtml = _renderRuntimeState(job.runtime_state);
|
||||
const agentRuntime = _agentRuntimeIndicator(job.runtime_state);
|
||||
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);
|
||||
});
|
||||
|
||||
// Legend: show phase groups
|
||||
const legend = PHASE_GROUPS.map(group => {
|
||||
return `<div class="legend-item"><span class="legend-dot" style="background: ${group.color}"></span><span>${group.label}</span></div>`;
|
||||
// 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('');
|
||||
|
||||
const itemsHtml = filtered.map(task => {
|
||||
// For each phase group, check if the task has completed any state in that group
|
||||
const phaseGroupHtml = PHASE_GROUPS.map(group => {
|
||||
const groupStates = group.states;
|
||||
const hasArtifact = groupStates.some(s => task.artifacts[s]);
|
||||
const isComplete = task.state === 'done' || hasArtifact;
|
||||
const isCurrent = groupStates.includes(task.state) && !hasArtifact;
|
||||
const cls = isComplete ? 'complete' : isCurrent ? 'in_progress' : 'incomplete';
|
||||
const icon = isComplete ? '✓' : isCurrent ? '◐' : '○';
|
||||
return `<div class="timeline-phase ${cls}" title="${group.label}: ${cls}">${icon}</div>`;
|
||||
}).join('');
|
||||
const statusClass = task.state === 'done' ? 'done' : task.state === 'blocked' ? 'blocked' : 'in_progress';
|
||||
const statusIcon = task.state === 'done' ? '✅' : task.state === 'blocked' ? '❌' : '🔄';
|
||||
const waveHtml = task.sub_tasks.length > 0
|
||||
? `<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';
|
||||
const stIcon = stStatus === 'pass' ? '✓' : stStatus === 'fail' ? '✗' : '○';
|
||||
return `<div class="timeline-subtask ${stStatus}"><span>${stIcon}</span><span>${st.name}</span></div>`;
|
||||
}).join('')}</div></div>`
|
||||
: '';
|
||||
return `<div class="timeline-item">
|
||||
<div class="timeline-item-header"><span class="timeline-item-name">${task.display_name}</span><span class="timeline-item-status">${statusIcon}</span></div>
|
||||
<div class="timeline-phases">${phaseGroupHtml}</div>${waveHtml}</div>`;
|
||||
}).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) {
|
||||
@@ -414,14 +559,19 @@ function switchView(view) {
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||||
const target = document.getElementById(`view-${view}`);
|
||||
if (target) target.classList.add('active');
|
||||
renderCurrentView();
|
||||
if (view === 'agent') {
|
||||
renderAgentTab(); // full render on switch; starts poller
|
||||
} else {
|
||||
_stopAgentPoller();
|
||||
renderCurrentView();
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentView() {
|
||||
switch (state.currentView) {
|
||||
case 'board': renderBoard(); break;
|
||||
case 'stats': renderStats(); break;
|
||||
case 'timeline': renderTimeline(); break;
|
||||
// Agent tab runs its own independent poller — not refreshed on global ticks
|
||||
}
|
||||
renderHeader();
|
||||
}
|
||||
@@ -451,10 +601,10 @@ function attachCardListeners() {
|
||||
function setupKeyboard() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
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 === '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 === '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); }
|
||||
@@ -496,15 +646,12 @@ function applyConfig(config) {
|
||||
state.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);
|
||||
}
|
||||
if (config.column_width && config.column_width >= 10) {
|
||||
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() {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="view-tabs">
|
||||
<button class="tab active" data-view="board">Board</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 class="header-right">
|
||||
@@ -80,8 +80,8 @@
|
||||
<div class="view" id="view-stats">
|
||||
<div class="stats-panel" id="stats-panel"></div>
|
||||
</div>
|
||||
<div class="view" id="view-timeline">
|
||||
<div class="timeline-panel" id="timeline-panel"></div>
|
||||
<div class="view" id="view-agent">
|
||||
<div class="agent-panel" id="agent-panel"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -107,10 +107,10 @@
|
||||
<div class="help-modal-content">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<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>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>r</kbd></td><td>Manual refresh</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-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; }
|
||||
.timeline-panel { max-width: 900px; margin: 0 auto; }
|
||||
.timeline-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.phase-legend { display: flex; align-items: center; gap: 10px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-secondary); }
|
||||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.legend-dot.complete { background: var(--success); }
|
||||
.legend-dot.in_progress { background: var(--warning); }
|
||||
.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;
|
||||
.agent-panel { max-width: 1100px; margin: 0 auto; }
|
||||
.bg-loading, .bg-empty, .bg-error { padding: 24px; text-align: center; color: var(--text-secondary); font-size: 13px; }
|
||||
.bg-error { color: var(--error); }
|
||||
.bg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
||||
.bg-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-md);
|
||||
padding: 14px 16px; box-shadow: var(--elevation-1);
|
||||
}
|
||||
.timeline-phase.complete { background: var(--success); color: white; }
|
||||
.timeline-phase.in_progress { background: var(--warning); color: var(--bg-primary); }
|
||||
.timeline-phase.incomplete { background: var(--bg-primary); color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.timeline-wave { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-color); }
|
||||
.timeline-wave h5 { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
|
||||
.timeline-subtask-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.timeline-subtask {
|
||||
display: flex; align-items: center; gap: 4px; padding: 3px 10px;
|
||||
background: var(--bg-primary); border-radius: var(--radius-sm); font-size: 11px; color: var(--text-secondary);
|
||||
}
|
||||
.timeline-subtask.pass { border-left: 2px solid var(--success); }
|
||||
.timeline-subtask.fail { border-left: 2px solid var(--error); }
|
||||
.timeline-subtask.incomplete { border-left: 2px solid var(--text-muted); }
|
||||
.bg-card.bg-status-ok { border-left: 3px solid var(--success); }
|
||||
.bg-card.bg-status-muted { border-left: 3px solid var(--text-muted); }
|
||||
.bg-card.bg-status-bad { border-left: 3px solid var(--error); }
|
||||
.bg-card.bg-status-inactive { border-left: 3px solid var(--text-muted); opacity: 0.55; }
|
||||
.bg-card-header { margin-bottom: 10px; }
|
||||
.bg-card-title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.bg-name { font-size: 14px; color: var(--text-primary); }
|
||||
.bg-name-inactive { color: var(--text-muted); }
|
||||
.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; }
|
||||
.bg-status-ok { background: var(--success-bg); color: var(--success); }
|
||||
.bg-status-muted { background: rgba(112,117,122,0.15); color: 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; }
|
||||
.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; }
|
||||
|
||||
@@ -18,6 +18,24 @@ from ..core.scope import detect_scope
|
||||
from ..core.task import discover_tasks, TaskState, COLUMN_HEADERS
|
||||
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_STATE_ARTIFACT = {
|
||||
"research": "SPEC.md",
|
||||
@@ -86,6 +104,8 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
self._serve_task(task_name)
|
||||
elif self.path == "/api/review-summary":
|
||||
self._serve_review_summary()
|
||||
elif self.path == "/api/scheduled":
|
||||
self._serve_scheduled()
|
||||
else:
|
||||
self._serve_static()
|
||||
|
||||
@@ -383,6 +403,24 @@ class DashboardHandler(SimpleHTTPRequestHandler):
|
||||
counts["pending"] += 1
|
||||
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):
|
||||
self.send_response(200)
|
||||
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 --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 --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
|
||||
@@ -262,7 +264,7 @@ def _all_task_dirs(project: Optional[str] = None) -> list[tuple[str, Path]]:
|
||||
if not base.exists():
|
||||
return tasks
|
||||
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))
|
||||
subtasks = entry / "subtasks"
|
||||
if subtasks.exists():
|
||||
@@ -1625,6 +1627,44 @@ LOOP_TICK_SCRIPT_SH = "automaton-loop-tick.sh"
|
||||
LOOP_TICK_SCRIPT_BAT = "automaton-loop-tick.bat"
|
||||
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:
|
||||
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)
|
||||
system = platform.system()
|
||||
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():
|
||||
plist.rename(plist.with_suffix(".plist.disabled"))
|
||||
try:
|
||||
@@ -1883,14 +1923,11 @@ def _disable_schedule(name: str, project: Optional[str]) -> None:
|
||||
capture_output=True, timeout=5, check=False)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
def _install_cron_block_generic(marker: str, stub_path: Path, interval: int) -> int:
|
||||
"""Install (or re-install) an idempotent cron block delimited by
|
||||
``# <marker>`` / ``# end <marker>``. Strips any prior block with the same
|
||||
marker before appending the new one. Returns 0 on success, 2 on failure."""
|
||||
minutes_interval = max(1, interval // 60)
|
||||
stub_path = loop_path / LOOP_TICK_SCRIPT_SH
|
||||
try:
|
||||
res = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
|
||||
timeout=5, check=False)
|
||||
@@ -1900,17 +1937,17 @@ def _install_cron_block(name: str, loop_path: Path, interval: int) -> int:
|
||||
kept = []
|
||||
inside = False
|
||||
for line in existing:
|
||||
if line.strip() == f"# automaton-loop:{name}":
|
||||
if line.strip() == f"# {marker}":
|
||||
inside = True
|
||||
continue
|
||||
if inside and line.strip() == f"# end automaton-loop:{name}":
|
||||
if inside and line.strip() == f"# end {marker}":
|
||||
inside = False
|
||||
continue
|
||||
if not inside:
|
||||
kept.append(line)
|
||||
kept.append(f"# automaton-loop:{name}")
|
||||
kept.append(f"# {marker}")
|
||||
kept.append(f"*/{minutes_interval} * * * * {stub_path}")
|
||||
kept.append(f"# end automaton-loop:{name}")
|
||||
kept.append(f"# end {marker}")
|
||||
try:
|
||||
subprocess.run(["crontab", "-"], input="\n".join(kept) + "\n",
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Inverse of _disable_schedule. Called from --resume-loop after pause clear."""
|
||||
loop_path = _loop_dir(name, project)
|
||||
system = platform.system()
|
||||
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")
|
||||
if disabled.exists() and not plist.exists():
|
||||
disabled.rename(plist)
|
||||
@@ -2028,7 +2075,7 @@ def cmd_install_schedule(args) -> int:
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
plist_dir = Path.home() / "Library" / "LaunchAgents"
|
||||
plist_dir = _launchagents_dir()
|
||||
plist_dir.mkdir(parents=True, exist_ok=True)
|
||||
plist_path = plist_dir / f"com.automaton.loop.{name}.plist"
|
||||
plist = (
|
||||
@@ -2072,6 +2119,251 @@ def cmd_install_schedule(args) -> int:
|
||||
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:
|
||||
name = args.pause_loop
|
||||
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("--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("--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")
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -2587,6 +2885,10 @@ def main():
|
||||
return cmd_can_continue(args)
|
||||
if args.loop_list:
|
||||
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:
|
||||
return cmd_create_task(args)
|
||||
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