feat(apps): run an app's own agents when its data/config.json changes

Apps store user settings in data/config.json (e.g. pr-dashboard's
tracked repo) and rely on their bundled agents to turn config into
data. Without this, changing a setting meant staring at an empty app
until the next cron tick. The apps-server watcher now debounces
config.json writes and fires a one-shot manual run of the app's owned
tasks (app--<slug>--*). Generic for any app; dynamic import keeps the
server decoupled from the bg-task runner at load time.
This commit is contained in:
Gagan 2026-07-10 15:15:24 +05:30
parent 7d38dc84a2
commit 5df26e6fca

View file

@ -683,6 +683,38 @@ function slugFromAbsolutePath(absolutePath: string): { slug: string; rel: string
return { slug, rel: segments.slice(1).join('/') }; return { slug, rel: segments.slice(1).join('/') };
} }
// Config-change → one-shot agent run. An app writes data/config.json when the
// user changes its settings (e.g. picks the repo to track); its bundled agents
// are what turn that config into fresh data. Without this kick the user would
// stare at an empty app until the next cron tick. Generic: any app, any agent
// the app owns (app--<slug>--*). Dynamic import keeps apps/server decoupled
// from the bg-task runner at module-load time.
const agentKickTimers = new Map<string, NodeJS.Timeout>();
function scheduleAgentKick(slug: string): void {
const existing = agentKickTimers.get(slug);
if (existing) clearTimeout(existing);
agentKickTimers.set(slug, setTimeout(() => {
agentKickTimers.delete(slug);
void (async () => {
try {
const tasksDir = path.join(path.dirname(APPS_DIR), 'bg-tasks');
const entries = await fsp.readdir(tasksDir).catch(() => [] as string[]);
const owned = entries.filter((e) => e.startsWith(`app--${slug}--`));
if (!owned.length) return;
const { runBackgroundTask } = await import('../background-tasks/runner.js');
for (const taskSlug of owned) {
console.log(`[Apps] ${slug}/data/config.json changed — running ${taskSlug}`);
void runBackgroundTask(taskSlug, 'manual').catch((e: unknown) => {
console.warn(`[Apps] config-change agent run failed for ${taskSlug}:`, e);
});
}
} catch (e) {
console.warn(`[Apps] config-change agent kick failed for ${slug}:`, e);
}
})();
}, 400));
}
async function startWatcher(): Promise<void> { async function startWatcher(): Promise<void> {
if (watcher) return; if (watcher) return;
const w = chokidar.watch(APPS_DIR, { const w = chokidar.watch(APPS_DIR, {
@ -695,6 +727,9 @@ async function startWatcher(): Promise<void> {
if (!hit || hit.rel.endsWith('.tmp') || /\.tmp-[0-9a-f]+$/.test(hit.rel)) return; if (!hit || hit.rel.endsWith('.tmp') || /\.tmp-[0-9a-f]+$/.test(hit.rel)) return;
const area: 'dist' | 'data' = hit.rel === 'data' || hit.rel.startsWith('data/') ? 'data' : 'dist'; const area: 'dist' | 'data' = hit.rel === 'data' || hit.rel.startsWith('data/') ? 'data' : 'dist';
scheduleChangeBroadcast(hit.slug, area, hit.rel); scheduleChangeBroadcast(hit.slug, area, hit.rel);
if (hit.rel === 'data/config.json' && (eventName === 'add' || eventName === 'change')) {
scheduleAgentKick(hit.slug);
}
}); });
w.on('error', (error: unknown) => { w.on('error', (error: unknown) => {
console.error('[Apps] watcher error:', error); console.error('[Apps] watcher error:', error);
@ -795,6 +830,8 @@ export async function shutdown(): Promise<void> {
for (const timer of reloadTimers.values()) clearTimeout(timer); for (const timer of reloadTimers.values()) clearTimeout(timer);
reloadTimers.clear(); reloadTimers.clear();
for (const timer of agentKickTimers.values()) clearTimeout(timer);
agentKickTimers.clear();
for (const clients of eventClients.values()) { for (const clients of eventClients.values()) {
for (const res of clients) { for (const res of clients) {