605 lines
30 KiB
TypeScript
605 lines
30 KiB
TypeScript
|
|
import { atom, read, update } from 'claude-code'
|
|||
|
|
import type { EngineInterface, Register, RenderChildren } from 'claude-code'
|
|||
|
|
|
|||
|
|
import type { Job, JobState, RunEntry, SkillInfo, Snapshot, UiState } from '../types'
|
|||
|
|
import { ASCII, MOOD_LABEL, THEMES, THEME_ORDER, foremanSvg, isThemeId } from './mascot'
|
|||
|
|
import type { Mood, Theme } from './mascot'
|
|||
|
|
import { describeSchedule, nextSlot, parseSchedule } from './schedule.mjs'
|
|||
|
|
|
|||
|
|
const PROJECT = '/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE'
|
|||
|
|
const ROOT = `${PROJECT}/foreman`
|
|||
|
|
const JOBS = `${ROOT}/jobs.json`
|
|||
|
|
const RUNNER = `${ROOT}/bin/runner.mjs`
|
|||
|
|
const PANE = 'foreman'
|
|||
|
|
const PANE_TITLE = '👷 Foreman'
|
|||
|
|
const POLL_MS = 2000
|
|||
|
|
const THEME_KEY = 'theme'
|
|||
|
|
|
|||
|
|
const EMPTY: Snapshot = { jobs: [], paused: false, states: {}, acks: [], skills: [] }
|
|||
|
|
const snapAtom = atom({ plugin: 'foreman', key: 'snap' } as const, EMPTY)
|
|||
|
|
const uiAtom = atom({ plugin: 'foreman', key: 'ui' } as const, { view: 'board' } as UiState)
|
|||
|
|
const themeAtom = atom({ plugin: 'foreman', key: 'theme' } as const, 'hivis')
|
|||
|
|
|
|||
|
|
type Dollar = EngineInterface
|
|||
|
|
|
|||
|
|
// ---------- reading Foreman's folder ----------
|
|||
|
|
|
|||
|
|
const parseJson = <T,>(text: string | undefined, fallback: T): T => {
|
|||
|
|
try {
|
|||
|
|
return text ? (JSON.parse(text) as T) : fallback
|
|||
|
|
} catch {
|
|||
|
|
return fallback
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function readText($: Dollar, path: string): Promise<string | undefined> {
|
|||
|
|
try {
|
|||
|
|
const got: unknown = await $.fs.read(path)
|
|||
|
|
return typeof got === 'string' ? got : undefined
|
|||
|
|
} catch {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function frontmatter(text: string): Record<string, string> {
|
|||
|
|
const m = /^---\n([\s\S]*?)\n---/.exec(text)
|
|||
|
|
const out: Record<string, string> = {}
|
|||
|
|
for (const line of m?.[1]?.split('\n') ?? []) {
|
|||
|
|
const kv = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line)
|
|||
|
|
if (kv?.[1]) out[kv[1]] = (kv[2] ?? '').trim()
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function listSkills($: Dollar, dir: string, source: SkillInfo['source']): Promise<SkillInfo[]> {
|
|||
|
|
const entries = await $.fs.list(dir).catch(() => [])
|
|||
|
|
const out: SkillInfo[] = []
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (entry.kind !== 'dir' || entry.name.startsWith('_') || entry.name.startsWith('.')) continue
|
|||
|
|
const path = `${dir}/${entry.name}/SKILL.md`
|
|||
|
|
const text = await readText($, path)
|
|||
|
|
if (text === undefined) continue
|
|||
|
|
const fm = frontmatter(text)
|
|||
|
|
out.push({ path, folder: entry.name, name: fm.name || entry.name, description: fm.description || '', source })
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let lastKey = ''
|
|||
|
|
|
|||
|
|
async function refresh($: Dollar): Promise<void> {
|
|||
|
|
const cfg = parseJson<{ paused?: boolean; node?: string; jobs?: Job[] }>(await readText($, JOBS), {})
|
|||
|
|
const jobs = Array.isArray(cfg.jobs) ? cfg.jobs : []
|
|||
|
|
const states: Record<string, JobState> = {}
|
|||
|
|
for (const job of jobs) {
|
|||
|
|
const st = parseJson<JobState | undefined>(await readText($, `${ROOT}/state/${job.id}.json`), undefined)
|
|||
|
|
if (st) states[job.id] = st
|
|||
|
|
}
|
|||
|
|
const acks = parseJson<string[]>(await readText($, `${ROOT}/state/acks.json`), [])
|
|||
|
|
const sched = parseJson<{ lastTick?: string }>(await readText($, `${ROOT}/state/scheduler.json`), {})
|
|||
|
|
const skills = [
|
|||
|
|
...(await listSkills($, `${ROOT}/skills`, 'foreman')),
|
|||
|
|
...(await listSkills($, `${PROJECT}/.claude/skills`, 'project')),
|
|||
|
|
]
|
|||
|
|
const snap: Snapshot = {
|
|||
|
|
jobs,
|
|||
|
|
paused: cfg.paused === true,
|
|||
|
|
...(cfg.node ? { node: cfg.node } : {}),
|
|||
|
|
states,
|
|||
|
|
acks: Array.isArray(acks) ? acks : [],
|
|||
|
|
skills,
|
|||
|
|
...(sched.lastTick ? { lastTick: sched.lastTick } : {}),
|
|||
|
|
}
|
|||
|
|
const key = JSON.stringify(snap)
|
|||
|
|
if (key === lastKey) return
|
|||
|
|
lastKey = key
|
|||
|
|
await update($, snapAtom, () => snap)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveJobs($: Dollar, change: (cfg: { paused: boolean; node?: string; jobs: Job[] }) => void): Promise<void> {
|
|||
|
|
const cfg = parseJson<{ paused?: boolean; node?: string; jobs?: Job[] }>(await readText($, JOBS), {})
|
|||
|
|
const next = { ...cfg, paused: cfg.paused === true, jobs: Array.isArray(cfg.jobs) ? cfg.jobs : [] }
|
|||
|
|
change(next)
|
|||
|
|
await $.fs.write(JOBS, `${JSON.stringify(next, null, 2)}\n`)
|
|||
|
|
await refresh($)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function setUi($: Dollar, patch: Partial<UiState>): Promise<void> {
|
|||
|
|
await update($, uiAtom, ui => ({ ...ui, message: undefined, confirmRemove: undefined, ...patch }))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|||
|
|
|
|||
|
|
async function runNow($: Dollar, job: Job): Promise<void> {
|
|||
|
|
if (!SAFE_ID.test(job.id)) return void $.ui.toast(`👷 Can't run "${job.id}": bad job id`)
|
|||
|
|
const snap = await read($, snapAtom)
|
|||
|
|
if (snap.states[job.id]?.status === 'running') return void $.ui.toast(`👷 ${job.name} is already running`)
|
|||
|
|
const node = snap.node ?? 'node'
|
|||
|
|
const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|||
|
|
const cmd = `nohup ${quote(node)} ${quote(RUNNER)} run ${job.id} >> ${quote(`${ROOT}/state/manual.log`)} 2>&1 &`
|
|||
|
|
await $.process.run(['/bin/sh', '-c', cmd], { cwd: ROOT, timeoutMs: 10_000 })
|
|||
|
|
$.ui.toast(`👷 Foreman is starting "${job.name}"`)
|
|||
|
|
$.clock.after(800, () => void refresh($))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openReport($: Dollar, job: Job | undefined, entry: RunEntry): Promise<void> {
|
|||
|
|
if (!entry.report) return
|
|||
|
|
const path = `${ROOT}/${entry.report}`
|
|||
|
|
const text = (await readText($, path)) ?? '(The report file is missing.)'
|
|||
|
|
await setUi($, {
|
|||
|
|
view: 'report',
|
|||
|
|
report: { title: job?.name ?? entry.runId, path, text, runId: entry.runId, ...(job ? { jobId: job.id } : {}) },
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function acknowledge($: Dollar, runId: string): Promise<void> {
|
|||
|
|
const acks = parseJson<string[]>(await readText($, `${ROOT}/state/acks.json`), [])
|
|||
|
|
if (!acks.includes(runId)) acks.push(runId)
|
|||
|
|
await $.fs.write(`${ROOT}/state/acks.json`, JSON.stringify(acks.slice(-500)))
|
|||
|
|
await refresh($)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function askClaude($: Dollar, job: Job | undefined, entry: RunEntry): Promise<void> {
|
|||
|
|
const when = new Date(entry.startedAt).toLocaleString()
|
|||
|
|
await $.prompt.submit({
|
|||
|
|
asUser: true,
|
|||
|
|
text:
|
|||
|
|
`Foreman flagged items in "${job?.name ?? entry.runId}" (run of ${when}). Read the report at ${ROOT}/${entry.report}. ` +
|
|||
|
|
'For each flagged item, check it against the app (use the MegaDash tools, megadash-direct) and tell me which ones can go to ready-to-post and which need me. ' +
|
|||
|
|
"Don't change anything in the app yet.",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- small helpers for drawing ----------
|
|||
|
|
|
|||
|
|
const pad2 = (n: number) => String(n).padStart(2, '0')
|
|||
|
|
const clock = (d: Date) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
|||
|
|
function whenLabel(d: Date, now: Date): string {
|
|||
|
|
const days = Math.round((new Date(d).setHours(0, 0, 0, 0) - new Date(now).setHours(0, 0, 0, 0)) / 86_400_000)
|
|||
|
|
if (days === 0) return `today ${clock(d)}`
|
|||
|
|
if (days === 1) return `tomorrow ${clock(d)}`
|
|||
|
|
if (days === -1) return `yesterday ${clock(d)}`
|
|||
|
|
return `${d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })} ${clock(d)}`
|
|||
|
|
}
|
|||
|
|
function ago(iso: string | undefined, now: Date): string {
|
|||
|
|
if (!iso) return '—'
|
|||
|
|
const s = Math.max(0, Math.round((now.getTime() - new Date(iso).getTime()) / 1000))
|
|||
|
|
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`
|
|||
|
|
}
|
|||
|
|
const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
|
|||
|
|
const slug = (t: string) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'job'
|
|||
|
|
|
|||
|
|
function mood(snap: Snapshot, nowMs: number): Mood {
|
|||
|
|
const states = Object.values(snap.states)
|
|||
|
|
if (states.some(s => s.status === 'running')) return 'running'
|
|||
|
|
const open = states.flatMap(s => s.history ?? []).filter(r => r.status !== 'clear' && !snap.acks.includes(r.runId))
|
|||
|
|
if (open.some(r => r.status === 'failed')) return 'failed'
|
|||
|
|
if (open.length) return 'attention'
|
|||
|
|
const last = states.map(s => s.lastRun).filter((r): r is RunEntry => !!r).sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0]
|
|||
|
|
return last && nowMs - new Date(last.startedAt).getTime() < 12 * 3600_000 ? 'clear' : 'idle'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- the module ----------
|
|||
|
|
|
|||
|
|
export const register: Register = on => {
|
|||
|
|
on('session.start', async ($, e, next) => {
|
|||
|
|
await $.command.register({ name: 'foreman', description: 'Open Foreman, the Mega Infrastructure site agent' })
|
|||
|
|
const saved = await $.store.get(THEME_KEY).catch(() => undefined)
|
|||
|
|
if (isThemeId(saved)) await update($, themeAtom, () => saved)
|
|||
|
|
await refresh($).catch(() => undefined)
|
|||
|
|
$.clock.every(POLL_MS, () => void refresh($).catch(() => undefined))
|
|||
|
|
return next(e)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
on('command.run', { command: 'foreman' }, async $ => {
|
|||
|
|
await refresh($).catch(() => undefined)
|
|||
|
|
await $.ui.open({ id: PANE, title: PANE_TITLE })
|
|||
|
|
return { text: '👷 Foreman is on site.' }
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|||
|
|
const ui = $.ui.resolve(e)
|
|||
|
|
const { Box, Text, Button } = ui
|
|||
|
|
const Input = 'Input' in ui ? ui.Input : undefined
|
|||
|
|
const Select = 'Select' in ui ? ui.Select : undefined
|
|||
|
|
const snap = await read($, snapAtom)
|
|||
|
|
const view = await read($, uiAtom)
|
|||
|
|
const themeId = await read($, themeAtom)
|
|||
|
|
const T: Theme = isThemeId(themeId) ? THEMES[themeId] : THEMES.hivis
|
|||
|
|
const now = new Date(await $.clock.now())
|
|||
|
|
const cols = e.props.bodyColumns ?? 80
|
|||
|
|
const isWide = cols >= 100
|
|||
|
|
const svg = 'Svg' in ui ? ui.Svg : undefined
|
|||
|
|
const jobById = (id: string) => snap.jobs.find(j => j.id === id)
|
|||
|
|
const m = mood(snap, now.getTime())
|
|||
|
|
const tickAge = snap.lastTick ? now.getTime() - new Date(snap.lastTick).getTime() : Infinity
|
|||
|
|
const schedulerOk = tickAge < 3 * 60_000
|
|||
|
|
|
|||
|
|
const section = (title: string, color: string, body: RenderChildren, right?: RenderChildren) => (
|
|||
|
|
<Box flexDirection="column" backgroundColor={T.panel} borderStyle="round" borderColor={T.line} paddingX={1}>
|
|||
|
|
<Box flexDirection="row" justifyContent="space-between">
|
|||
|
|
<Text color={color} bold>{title}</Text>
|
|||
|
|
{right ?? null}
|
|||
|
|
</Box>
|
|||
|
|
{body}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
// Buttons, fields and selects take no text colour: the app draws them in its own
|
|||
|
|
// (dark text on a light app theme). Dark themes seat them on light chips to stay readable.
|
|||
|
|
const ctl = (child: RenderChildren) =>
|
|||
|
|
T.control ? <Box backgroundColor={T.control} paddingX={1} flexDirection="column">{child}</Box> : child
|
|||
|
|
const controlRow = () => ({
|
|||
|
|
flexDirection: 'row' as const,
|
|||
|
|
gap: 1,
|
|||
|
|
flexWrap: 'wrap' as const,
|
|||
|
|
...(T.control ? { backgroundColor: T.control, paddingX: 1 } : {}),
|
|||
|
|
})
|
|||
|
|
const pill = (text: string, color: string) => <Text color={color} bold>{`● ${text}`}</Text>
|
|||
|
|
|
|||
|
|
// ----- header -----
|
|||
|
|
const header = (
|
|||
|
|
<Box flexDirection="row" gap={2} alignItems="center">
|
|||
|
|
{svg
|
|||
|
|
? h(svg, { source: foremanSvg(m, T), alt: `Foreman: ${MOOD_LABEL[m]}`, width: 132, height: 100, isInteractive: true })
|
|||
|
|
: <Text color={T.run} bold>{ASCII[m]}</Text>}
|
|||
|
|
<Box flexDirection="column" flexGrow={1}>
|
|||
|
|
<Text color={T.text} bold>Foreman</Text>
|
|||
|
|
<Text color={T.muted}>Mega Infrastructure · site agent · runs on MiniMax</Text>
|
|||
|
|
<Text color={m === 'attention' ? T.warn : m === 'failed' ? T.bad : m === 'running' ? T.run : T.sub}>{MOOD_LABEL[m]}</Text>
|
|||
|
|
<Box flexDirection="row" gap={2} marginTop={1}>
|
|||
|
|
{snap.paused
|
|||
|
|
? pill('Schedule paused', T.warn)
|
|||
|
|
: schedulerOk
|
|||
|
|
? pill(`Scheduler on · ${ago(snap.lastTick, now)} ago`, T.good)
|
|||
|
|
: pill('Scheduler not running', T.bad)}
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ----- toolbar -----
|
|||
|
|
const toolbar = (
|
|||
|
|
<Box {...controlRow()}>
|
|||
|
|
<Button key="nav-board" variant={view.view === 'board' ? 'primary' : 'secondary'} onPress={() => void setUi($, { view: 'board' })}>Board</Button>
|
|||
|
|
<Button key="nav-skills" variant={view.view === 'skills' ? 'primary' : 'secondary'} onPress={() => void setUi($, { view: 'skills' })}>Skills</Button>
|
|||
|
|
<Button key="pause" onPress={() => void saveJobs($, cfg => void (cfg.paused = !cfg.paused))}>
|
|||
|
|
{snap.paused ? '▶ Resume schedule' : '⏸ Pause schedule'}
|
|||
|
|
</Button>
|
|||
|
|
<Button key="theme" onPress={async () => {
|
|||
|
|
const nextId = await update($, themeAtom, cur => {
|
|||
|
|
const i = THEME_ORDER.indexOf(isThemeId(cur) ? cur : 'hivis')
|
|||
|
|
return THEME_ORDER[(i + 1) % THEME_ORDER.length] ?? 'hivis'
|
|||
|
|
})
|
|||
|
|
await $.store.set(THEME_KEY, nextId)
|
|||
|
|
}}>{`🎨 ${T.name}`}</Button>
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const message = view.message ? <Text color={T.warn} bold>{view.message}</Text> : null
|
|||
|
|
|
|||
|
|
// ----- board -----
|
|||
|
|
const boardView = () => {
|
|||
|
|
type Card = { key: string; title: string; lines: Array<[string, string]>; color: string; buttons: RenderChildren }
|
|||
|
|
const upNext: Card[] = []
|
|||
|
|
const running: Card[] = []
|
|||
|
|
const done: Card[] = []
|
|||
|
|
const attention: Card[] = []
|
|||
|
|
const startOfToday = new Date(now).setHours(0, 0, 0, 0)
|
|||
|
|
|
|||
|
|
for (const job of snap.jobs) {
|
|||
|
|
const st = snap.states[job.id]
|
|||
|
|
if (st?.status === 'running' && st.current) {
|
|||
|
|
const last = st.current.activity.at(-1)
|
|||
|
|
running.push({
|
|||
|
|
key: `run-${job.id}`,
|
|||
|
|
title: job.name,
|
|||
|
|
color: T.run,
|
|||
|
|
lines: [
|
|||
|
|
[`${st.current.trigger === 'manual' ? 'Manual' : 'Scheduled'} · ${ago(st.current.startedAt, now)} · ${st.current.toolCount} tools`, T.sub],
|
|||
|
|
...(last ? ([[clip(last.text, 70), T.muted]] as Array<[string, string]>) : []),
|
|||
|
|
],
|
|||
|
|
buttons: null,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
const sched = parseSchedule(job.schedule)
|
|||
|
|
const nxt = job.enabled && !snap.paused ? nextSlot(sched, now) : null
|
|||
|
|
if (nxt && nxt.getTime() - now.getTime() < 36 * 3600_000) {
|
|||
|
|
upNext.push({
|
|||
|
|
key: `next-${job.id}`,
|
|||
|
|
title: job.name,
|
|||
|
|
color: T.accent,
|
|||
|
|
lines: [[whenLabel(nxt, now), T.sub]],
|
|||
|
|
buttons: ctl(<Button key={`runnow-${job.id}`} plain dimColor={!T.control} onPress={() => void runNow($, job)}>▶ Run now</Button>),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
for (const r of st?.history ?? []) {
|
|||
|
|
const isOpen = r.status !== 'clear' && !snap.acks.includes(r.runId)
|
|||
|
|
const started = new Date(r.startedAt)
|
|||
|
|
const label = `${whenLabel(started, now)} · ${r.trigger}`
|
|||
|
|
if (isOpen) {
|
|||
|
|
attention.push({
|
|||
|
|
key: `att-${r.runId}`,
|
|||
|
|
title: job.name,
|
|||
|
|
color: r.status === 'failed' ? T.bad : T.warn,
|
|||
|
|
lines: [
|
|||
|
|
[`${r.status === 'failed' ? 'Failed' : `${r.attentionCount ?? '?'} to check`} · ${label}`, r.status === 'failed' ? T.bad : T.warn],
|
|||
|
|
...(r.summary ? ([[clip(r.summary, 110), T.sub]] as Array<[string, string]>) : []),
|
|||
|
|
],
|
|||
|
|
buttons: (
|
|||
|
|
<Box {...controlRow()}>
|
|||
|
|
{r.report ? <Button key={`rep-${r.runId}`} plain onPress={() => void openReport($, job, r)}>📄 Report</Button> : null}
|
|||
|
|
{r.report && r.status === 'attention' ? <Button key={`ask-${r.runId}`} plain onPress={() => void askClaude($, job, r)}>✦ Ask Claude</Button> : null}
|
|||
|
|
<Button key={`ack-${r.runId}`} plain onPress={() => void acknowledge($, r.runId)}>✓ Reviewed</Button>
|
|||
|
|
</Box>
|
|||
|
|
),
|
|||
|
|
})
|
|||
|
|
} else if (started.getTime() >= startOfToday) {
|
|||
|
|
done.push({
|
|||
|
|
key: `done-${r.runId}`,
|
|||
|
|
title: job.name,
|
|||
|
|
color: T.good,
|
|||
|
|
lines: [[`${r.status === 'clear' ? 'All clear' : 'Reviewed'} · ${label}`, T.good]],
|
|||
|
|
buttons: r.report ? ctl(<Button key={`rep-${r.runId}`} plain dimColor={!T.control} onPress={() => void openReport($, job, r)}>📄 Report</Button>) : null,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
upNext.sort((a, b) => a.lines[0]![0].localeCompare(b.lines[0]![0]))
|
|||
|
|
|
|||
|
|
const column = (title: string, color: string, cards: Card[], empty: string) => (
|
|||
|
|
<Box flexDirection="column" flexGrow={1} flexShrink={1} width={isWide ? '25%' : '100%'} backgroundColor={T.chip} borderStyle="round" borderColor={T.line} paddingX={1}>
|
|||
|
|
<Text color={color} bold>{`${title} · ${cards.length}`}</Text>
|
|||
|
|
{cards.length === 0 ? <Text color={T.muted}>{empty}</Text> : null}
|
|||
|
|
{cards.slice(0, 6).map(c => (
|
|||
|
|
<Box flexDirection="column" backgroundColor={T.panel} borderStyle="round" borderColor={c.color} paddingX={1}>
|
|||
|
|
<Text color={T.text} bold wrap="truncate-end">{c.title}</Text>
|
|||
|
|
{c.lines.map(([t, col]) => <Text color={col} wrap="wrap">{t}</Text>)}
|
|||
|
|
{c.buttons}
|
|||
|
|
</Box>
|
|||
|
|
))}
|
|||
|
|
{cards.length > 6 ? <Text color={T.muted}>{`+${cards.length - 6} more`}</Text> : null}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const board = (
|
|||
|
|
<Box flexDirection={isWide ? 'row' : 'column'} gap={1}>
|
|||
|
|
{column('UP NEXT', T.accent, upNext, snap.paused ? 'Schedule paused' : 'Nothing scheduled')}
|
|||
|
|
{column('RUNNING', T.run, running, 'Nobody out')}
|
|||
|
|
{column('DONE TODAY', T.good, done, 'No runs yet today')}
|
|||
|
|
{column('NEEDS ATTENTION', T.warn, attention, 'All clear 👍')}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const jobRows = snap.jobs.map(job => {
|
|||
|
|
const sched = parseSchedule(job.schedule)
|
|||
|
|
const st = snap.states[job.id]
|
|||
|
|
const last = st?.lastRun
|
|||
|
|
const nxt = job.enabled && !snap.paused ? nextSlot(sched, now) : null
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" borderStyle="round" borderColor={job.enabled ? T.accent : T.line} paddingX={1}>
|
|||
|
|
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|||
|
|
{ctl(<Button key={`toggle-${job.id}`} plain onPress={() => void saveJobs($, cfg => {
|
|||
|
|
const j = cfg.jobs.find(x => x.id === job.id)
|
|||
|
|
if (j) j.enabled = !j.enabled
|
|||
|
|
})}>{job.enabled ? '🟢 On' : '⚪ Off'}</Button>)}
|
|||
|
|
<Text color={T.text} bold>{job.name}</Text>
|
|||
|
|
{st?.status === 'running' ? pill('running', T.run) : null}
|
|||
|
|
</Box>
|
|||
|
|
<Text color={sched.kind === 'error' ? T.bad : T.sub}>{`🗓 ${describeSchedule(sched)}${nxt ? ` · next ${whenLabel(nxt, now)}` : ''}`}</Text>
|
|||
|
|
{last ? (
|
|||
|
|
<Text color={last.status === 'clear' ? T.good : last.status === 'failed' ? T.bad : T.warn}>
|
|||
|
|
{`Last: ${last.status === 'clear' ? 'all clear' : last.status === 'failed' ? 'failed' : `${last.attentionCount ?? '?'} to check`} · ${whenLabel(new Date(last.startedAt), now)}`}
|
|||
|
|
</Text>
|
|||
|
|
) : <Text color={T.muted}>Never run yet</Text>}
|
|||
|
|
<Box {...controlRow()}>
|
|||
|
|
<Button key={`run-${job.id}`} onPress={() => void runNow($, job)}>▶ Run now</Button>
|
|||
|
|
<Button key={`edit-${job.id}`} onPress={() => void setUi($, { view: 'job', jobId: job.id })}>✎ Edit</Button>
|
|||
|
|
{last?.report ? <Button key={`last-${job.id}`} onPress={() => void openReport($, job, last)}>📄 Last report</Button> : null}
|
|||
|
|
</Box>
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" gap={1}>
|
|||
|
|
{board}
|
|||
|
|
{section(`JOBS · ${snap.jobs.length}`, T.accent,
|
|||
|
|
<Box flexDirection="column" gap={0}>
|
|||
|
|
{snap.jobs.length === 0 ? <Text color={T.muted}>No jobs yet. Open Skills and add one to the board.</Text> : jobRows}
|
|||
|
|
</Box>,
|
|||
|
|
ctl(<Button key="add-job" plain onPress={() => void setUi($, { view: 'skills' })}>+ Add from a skill</Button>))}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ----- job editor -----
|
|||
|
|
const jobView = () => {
|
|||
|
|
const job = view.jobId ? jobById(view.jobId) : undefined
|
|||
|
|
if (!job) return <Text color={T.muted}>That job is gone.</Text>
|
|||
|
|
const sched = parseSchedule(job.schedule)
|
|||
|
|
const st = snap.states[job.id]
|
|||
|
|
const patch = (fn: (j: Job) => void) => saveJobs($, cfg => {
|
|||
|
|
const j = cfg.jobs.find(x => x.id === job.id)
|
|||
|
|
if (j) fn(j)
|
|||
|
|
})
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" gap={1}>
|
|||
|
|
{section(`EDIT · ${job.name}`, T.accent,
|
|||
|
|
<Box flexDirection="column" gap={1}>
|
|||
|
|
<Text color={T.muted} wrap="truncate-start">{`Skill: ${job.skill}`}</Text>
|
|||
|
|
{Input ? ctl(
|
|||
|
|
<Input key={`name-${job.id}`} label="Name" value={job.name} submitLabel="Save"
|
|||
|
|
onSubmit={v => void (v.trim() ? patch(j => void (j.name = v.trim())) : undefined)} />
|
|||
|
|
) : <Text color={T.text}>{`Name: ${job.name}`}</Text>}
|
|||
|
|
{Input ? ctl(
|
|||
|
|
<Input key={`sched-${job.id}`} label="When" value={job.schedule} submitLabel="Save"
|
|||
|
|
placeholder="mon-fri 08:00 11:00 14:00"
|
|||
|
|
onSubmit={async v => {
|
|||
|
|
const parsed = parseSchedule(v)
|
|||
|
|
if (parsed.kind === 'error') return void setUi($, { view: 'job', jobId: job.id, message: parsed.error })
|
|||
|
|
await patch(j => void (j.schedule = v.trim() || 'manual'))
|
|||
|
|
$.ui.toast(`👷 ${describeSchedule(parsed)}`)
|
|||
|
|
}} />
|
|||
|
|
) : <Text color={T.text}>{`When: ${job.schedule}`}</Text>}
|
|||
|
|
<Text color={sched.kind === 'error' ? T.bad : T.good}>{`→ ${describeSchedule(sched)}`}</Text>
|
|||
|
|
<Text color={T.muted}>Examples: mon-fri 08:00 11:00 14:00 · daily 07:30 · every 2h mon-fri 07:00-17:00 · manual</Text>
|
|||
|
|
{Select ? ctl(
|
|||
|
|
<Select key={`perm-${job.id}`} label="Permissions" value={job.permission ?? 'smart'}
|
|||
|
|
options={[
|
|||
|
|
{ value: 'smart', label: 'Careful (smart): asks MiniMax to stay cautious' },
|
|||
|
|
{ value: 'full', label: 'Full access: may change files and run commands' },
|
|||
|
|
]}
|
|||
|
|
onSelect={v => void patch(j => void (j.permission = v === 'full' ? 'full' : 'smart'))} />
|
|||
|
|
) : null}
|
|||
|
|
{Input ? ctl(
|
|||
|
|
<Input key={`timeout-${job.id}`} label="Time limit (minutes)" value={String(job.timeoutMin ?? 20)} submitLabel="Save"
|
|||
|
|
onSubmit={v => {
|
|||
|
|
const n = Math.round(Number(v))
|
|||
|
|
if (!Number.isFinite(n) || n < 1 || n > 120) return void setUi($, { view: 'job', jobId: job.id, message: 'Time limit is 1 to 120 minutes.' })
|
|||
|
|
void patch(j => void (j.timeoutMin = n))
|
|||
|
|
}} />
|
|||
|
|
) : null}
|
|||
|
|
<Box {...controlRow()}>
|
|||
|
|
<Button key={`etoggle-${job.id}`} variant="primary" onPress={() => void patch(j => void (j.enabled = !j.enabled))}>
|
|||
|
|
{job.enabled ? '🟢 On: turn off' : '⚪ Off: turn on'}
|
|||
|
|
</Button>
|
|||
|
|
<Button key={`erun-${job.id}`} onPress={() => void runNow($, job)}>▶ Run now</Button>
|
|||
|
|
{view.confirmRemove === job.id ? (
|
|||
|
|
<Button key={`econfirm-${job.id}`} onPress={async () => {
|
|||
|
|
await saveJobs($, cfg => void (cfg.jobs = cfg.jobs.filter(x => x.id !== job.id)))
|
|||
|
|
await setUi($, { view: 'board' })
|
|||
|
|
}}>Yes, remove it</Button>
|
|||
|
|
) : (
|
|||
|
|
<Button key={`eremove-${job.id}`} dimColor onPress={() => void setUi($, { view: 'job', jobId: job.id, confirmRemove: job.id })}>Remove job</Button>
|
|||
|
|
)}
|
|||
|
|
<Button key="eback" onPress={() => void setUi($, { view: 'board' })}>← Back</Button>
|
|||
|
|
</Box>
|
|||
|
|
</Box>)}
|
|||
|
|
{section('RECENT RUNS', T.accent,
|
|||
|
|
<Box flexDirection="column">
|
|||
|
|
{(st?.history ?? []).length === 0 ? <Text color={T.muted}>No runs yet.</Text> : null}
|
|||
|
|
{(st?.history ?? []).slice(0, 8).map(r => (
|
|||
|
|
<Box flexDirection="row" gap={1}>
|
|||
|
|
<Text color={r.status === 'clear' ? T.good : r.status === 'failed' ? T.bad : T.warn} bold>
|
|||
|
|
{r.status === 'clear' ? '✓' : r.status === 'failed' ? '✗' : '!'}
|
|||
|
|
</Text>
|
|||
|
|
<Text color={T.sub}>{`${whenLabel(new Date(r.startedAt), now)} · ${r.trigger}`}</Text>
|
|||
|
|
{r.report ? ctl(<Button key={`hrep-${r.runId}`} plain dimColor={!T.control} onPress={() => void openReport($, job, r)}>📄 Report</Button>) : null}
|
|||
|
|
</Box>
|
|||
|
|
))}
|
|||
|
|
</Box>)}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ----- skills -----
|
|||
|
|
const skillsView = () => {
|
|||
|
|
const onBoard = new Set(snap.jobs.map(j => j.skill))
|
|||
|
|
const relOrAbs = (p: string) => (p.startsWith(`${ROOT}/`) ? p.slice(ROOT.length + 1) : p)
|
|||
|
|
const addJob = async (skillPath: string, name: string) => {
|
|||
|
|
let added: Job | undefined
|
|||
|
|
await saveJobs($, cfg => {
|
|||
|
|
let id = slug(name)
|
|||
|
|
for (let i = 2; cfg.jobs.some(j => j.id === id); i++) id = `${slug(name)}-${i}`
|
|||
|
|
added = { id, name, skill: relOrAbs(skillPath), schedule: 'mon-fri 08:00', enabled: false, permission: 'smart', timeoutMin: 20 }
|
|||
|
|
cfg.jobs.push(added)
|
|||
|
|
})
|
|||
|
|
if (added) {
|
|||
|
|
$.ui.toast(`👷 "${name}" is on the board (off until you turn it on)`)
|
|||
|
|
await setUi($, { view: 'job', jobId: added.id })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" gap={1}>
|
|||
|
|
{section('SKILLS', T.accent,
|
|||
|
|
<Box flexDirection="column" gap={0}>
|
|||
|
|
<Text color={T.muted}>A skill is one job's instructions. Add it to the board, then set when it runs.</Text>
|
|||
|
|
{snap.skills.length === 0 ? <Text color={T.muted}>No skills found in foreman/skills yet.</Text> : null}
|
|||
|
|
{snap.skills.map(s => {
|
|||
|
|
const added = onBoard.has(relOrAbs(s.path))
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" borderStyle="round" borderColor={added ? T.good : T.line} paddingX={1}>
|
|||
|
|
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|||
|
|
<Text color={T.text} bold>{s.name}</Text>
|
|||
|
|
<Text color={T.muted}>{s.source === 'project' ? '(project skill)' : ''}</Text>
|
|||
|
|
</Box>
|
|||
|
|
{s.description ? <Text color={T.sub} wrap="wrap">{s.description}</Text> : null}
|
|||
|
|
{added
|
|||
|
|
? <Text color={T.good}>✓ On the board</Text>
|
|||
|
|
: ctl(<Button key={`addskill-${s.path}`} variant="primary" onPress={() => void addJob(s.path, s.name)}>+ Add to board</Button>)}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
})}
|
|||
|
|
</Box>)}
|
|||
|
|
{section('LOAD A SKILL FILE', T.accent,
|
|||
|
|
<Box flexDirection="column">
|
|||
|
|
<Text color={T.muted} wrap="wrap">Any SKILL.md (or .md) on this Mac. New skills: copy foreman/skills/_template and fill it in.</Text>
|
|||
|
|
{Input ? ctl(
|
|||
|
|
<Input key="loadpath" label="Path" placeholder="/Volumes/EXT/.../SKILL.md" submitLabel="Load"
|
|||
|
|
onSubmit={async v => {
|
|||
|
|
const p = v.trim()
|
|||
|
|
const text = p.endsWith('.md') ? await readText($, p) : undefined
|
|||
|
|
if (text === undefined) return void setUi($, { view: 'skills', message: `No readable .md file at ${p}` })
|
|||
|
|
const fm = frontmatter(text)
|
|||
|
|
await addJob(p, fm.name || p.split('/').slice(-2, -1)[0] || 'job')
|
|||
|
|
}} />
|
|||
|
|
) : <Text color={T.muted}>(Loading by path needs the desktop or terminal app.)</Text>}
|
|||
|
|
</Box>)}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ----- report -----
|
|||
|
|
const reportView = () => {
|
|||
|
|
const r = view.report
|
|||
|
|
if (!r) return <Text color={T.muted}>No report open.</Text>
|
|||
|
|
const job = r.jobId ? jobById(r.jobId) : undefined
|
|||
|
|
const entry = job ? snap.states[job.id]?.history?.find(x => x.runId === r.runId) : undefined
|
|||
|
|
const lines = r.text.split('\n')
|
|||
|
|
const out: RenderChildren[] = []
|
|||
|
|
for (const raw of lines.slice(0, 220)) {
|
|||
|
|
const line = raw.replace(/\*\*|`/g, '')
|
|||
|
|
if (/^\s*\|?\s*:?-{2,}/.test(line)) continue
|
|||
|
|
if (/^#{1,6}\s/.test(line)) out.push(<Text color={T.accent} bold>{line.replace(/^#+\s*/, '')}</Text>)
|
|||
|
|
else if (/^\s*VERDICT:/i.test(line)) out.push(<Text color={/CLEAR/i.test(line) ? T.good : T.warn} bold>{line.trim()}</Text>)
|
|||
|
|
else if (line.trim().startsWith('|')) out.push(<Text color={T.text} wrap="wrap">{`• ${line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()).join(' · ')}`}</Text>)
|
|||
|
|
else if (line.trim() === '---') out.push(<Text color={T.line}>{'─'.repeat(Math.max(10, Math.min(60, cols - 8)))}</Text>)
|
|||
|
|
else out.push(<Text color={line.startsWith('- ') ? T.sub : T.text} wrap="wrap">{line || ' '}</Text>)
|
|||
|
|
}
|
|||
|
|
if (lines.length > 220) out.push(<Text color={T.muted}>{`… ${lines.length - 220} more lines in ${r.path}`}</Text>)
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" gap={1}>
|
|||
|
|
<Box {...controlRow()}>
|
|||
|
|
<Button key="rback" onPress={() => void setUi($, { view: 'board' })}>← Back</Button>
|
|||
|
|
{entry && entry.status === 'attention' && !snap.acks.includes(entry.runId)
|
|||
|
|
? <Button key="rask" variant="primary" onPress={() => void askClaude($, job, entry)}>✦ Ask Claude to review</Button>
|
|||
|
|
: null}
|
|||
|
|
{entry && entry.status !== 'clear' && !snap.acks.includes(entry.runId)
|
|||
|
|
? <Button key="rack" onPress={() => void acknowledge($, entry.runId)}>✓ Mark reviewed</Button>
|
|||
|
|
: null}
|
|||
|
|
<Button key="rcopy" onPress={p => void $.ui.copy({ text: r.path, surface: p.surface })}>Copy path</Button>
|
|||
|
|
</Box>
|
|||
|
|
{section(`REPORT · ${r.title}`, T.accent, <Box flexDirection="column">{out}</Box>)}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const body =
|
|||
|
|
view.view === 'job' ? jobView() : view.view === 'skills' ? skillsView() : view.view === 'report' ? reportView() : boardView()
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Box flexDirection="column" gap={1} backgroundColor={T.bg} padding={1}>
|
|||
|
|
{header}
|
|||
|
|
{toolbar}
|
|||
|
|
{message}
|
|||
|
|
{!schedulerOk && !snap.paused ? (
|
|||
|
|
<Text color={T.bad} wrap="wrap">
|
|||
|
|
{`The Mac scheduler hasn't checked in${snap.lastTick ? ` for ${ago(snap.lastTick, now)}` : ''}. Scheduled jobs won't run until it does (run: node foreman/bin/runner.mjs install).`}
|
|||
|
|
</Text>
|
|||
|
|
) : null}
|
|||
|
|
{body}
|
|||
|
|
</Box>
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
}
|