ograph/packages/dispatcher/src/scheduler.ts
小橘 🍊 e82fe8eaba
feat: OGraph Dispatcher — dual-loop actor for task notification (#4 P0) (#17)
* feat: add packages/dispatcher — dual-loop OGraph projection watcher + OC scheduler

Adds a new Node.js daemon that:
- Loop A (ProjectionWatcher): polls OGraph projections, diffs against
  snapshot, merges changes into a pending queue.
  - Idle: 30s poll interval; active (changes detected): 5s
- Loop B (OcScheduler): polls OC session-status, pushes pending queue
  when OC has available slots (>= minAvailable).
  - Idle (no pending): 60s; active (pending): 5s
  - Cooldown of 60s after each push to avoid spam

Tech:
- TypeScript + esbuild (zero runtime external deps)
- Graceful error handling: each poll is independent try-catch, errors
  logged but never crash the process
- Config from ~/.config/ograph/dispatcher.json + env-var overrides
- OGRAPH_CONFIG_FILE env var for config path override
- Push via /tmp/ograph-dispatch.json + openclaw message send (best-effort)

Build: npm run build → dist/index.js
Run:   node dist/index.js

* fix: address PR #17 review — package name, tests, shell safety, first-run

---------

Co-authored-by: 小墨 <xiaomooo@shazhou.work>
2026-04-13 10:01:48 +08:00

156 lines
5.4 KiB
TypeScript

// Loop B: OC Scheduler
// Polls OC busy/idle state, pushes pending queue when OC is available.
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import type { DispatcherConfig, PendingEntry } from './types.js';
import { OcClient } from './oc-client.js';
function ts(): string {
return new Date().toISOString();
}
/** Dispatch file path — a simple push mechanism readable by OC / other tools */
const DISPATCH_FILE = '/tmp/ograph-dispatch.json';
export class OcScheduler {
private readonly client: OcClient;
private running = false;
private timer: ReturnType<typeof setTimeout> | null = null;
private lastPushAt = 0;
constructor(
private readonly config: DispatcherConfig,
/** Shared pending queue (read + cleared by Scheduler) */
private readonly pending: Map<string, PendingEntry>,
) {
this.client = new OcClient(config);
}
start(): void {
if (this.running) return;
this.running = true;
console.log(`[${ts()}] [scheduler] started — OC status: ${this.config.oc.statusEndpoint}`);
void this.poll();
}
stop(): void {
this.running = false;
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
console.log(`[${ts()}] [scheduler] stopped`);
}
private scheduleNext(delayMs: number): void {
if (!this.running) return;
this.timer = setTimeout(() => {
void this.poll();
}, delayMs);
}
private async poll(): Promise<void> {
if (!this.running) return;
const { schedulerIdle, schedulerActive, cooldownAfterPush } = this.config.intervals;
try {
const hasPending = this.pending.size > 0;
// Check cooldown first
const now = Date.now();
if (this.lastPushAt > 0 && now - this.lastPushAt < cooldownAfterPush) {
const remainMs = cooldownAfterPush - (now - this.lastPushAt);
console.log(`[${ts()}] [scheduler] in cooldown — ${Math.ceil(remainMs / 1000)}s remaining`);
this.scheduleNext(hasPending ? schedulerActive : schedulerIdle);
return;
}
if (!hasPending) {
// Nothing to push; low-frequency idle polling
this.scheduleNext(schedulerIdle);
return;
}
// We have pending items — check OC availability
let available = false;
try {
available = await this.client.isAvailable();
console.log(`[${ts()}] [scheduler] OC available=${available} pending=${this.pending.size}`);
} catch (err) {
console.warn(`[${ts()}] [scheduler] OC status check failed: ${err instanceof Error ? err.message : String(err)}`);
// Can't determine status — back off
this.scheduleNext(schedulerActive);
return;
}
if (available) {
await this.push();
this.lastPushAt = Date.now();
this.scheduleNext(schedulerIdle); // after push, slow down
} else {
// OC busy but we have work — keep polling actively
this.scheduleNext(schedulerActive);
}
} catch (err) {
console.error(`[${ts()}] [scheduler] poll error: ${err instanceof Error ? err.message : String(err)}`);
this.scheduleNext(schedulerActive);
}
}
private async push(): Promise<void> {
const entries = Array.from(this.pending.values());
if (entries.length === 0) return;
const message = this.buildMessage(entries);
console.log(`[${ts()}] [scheduler] pushing ${entries.length} change(s) to OC`);
console.log(`[${ts()}] [scheduler] message:\n${message}`);
// ── Strategy 1: write dispatch file ─────────────────────────────────────
try {
mkdirSync('/tmp', { recursive: true });
const payload = {
pushedAt: new Date().toISOString(),
changes: entries,
message,
};
writeFileSync(DISPATCH_FILE, JSON.stringify(payload, null, 2), 'utf-8');
console.log(`[${ts()}] [scheduler] dispatch file written: ${DISPATCH_FILE}`);
} catch (err) {
console.warn(`[${ts()}] [scheduler] failed to write dispatch file: ${err instanceof Error ? err.message : String(err)}`);
}
// ── Strategy 2: openclaw message send (best-effort) ──────────────────────
try {
execFileSync('openclaw', ['message', 'send', message], { timeout: 10_000, stdio: 'pipe' });
console.log(`[${ts()}] [scheduler] openclaw message send OK`);
} catch (err) {
// Not fatal — openclaw may not be in PATH or the channel may not be configured
console.warn(`[${ts()}] [scheduler] openclaw message send failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
}
// Clear pending after successful push attempt
this.pending.clear();
console.log(`[${ts()}] [scheduler] pending queue cleared`);
}
private buildMessage(entries: PendingEntry[]): string {
const lines: string[] = ['🔔 OGraph Projection Changes Detected\n'];
for (const entry of entries) {
const age = Math.round((Date.now() - entry.firstDetectedAt) / 1000);
lines.push(`• **${entry.name}**`);
lines.push(` Changes: ${entry.changeCount} | Age: ${age}s`);
lines.push(` Previous: ${JSON.stringify(entry.previousValue)}`);
lines.push(` Current: ${JSON.stringify(entry.currentValue)}`);
lines.push('');
}
lines.push(`Total: ${entries.length} projection(s) changed`);
return lines.join('\n');
}
}