小橘 🍊 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

62 lines
2.4 KiB
TypeScript

// OGraph Dispatcher — entry point
// Starts Loop A (ProjectionWatcher) and Loop B (OcScheduler) independently.
import { loadConfig } from './config.js';
import { ProjectionWatcher } from './watcher.js';
import { OcScheduler } from './scheduler.js';
import type { PendingEntry } from './types.js';
function ts(): string {
return new Date().toISOString();
}
async function main(): Promise<void> {
console.log(`[${ts()}] [dispatcher] OGraph Dispatcher starting...`);
const config = loadConfig();
console.log(`[${ts()}] [dispatcher] config loaded`);
console.log(`[${ts()}] [dispatcher] ograph.endpoint = ${config.ograph.endpoint}`);
console.log(`[${ts()}] [dispatcher] ograph.projections = [${config.ograph.projections.join(', ')}]`);
console.log(`[${ts()}] [dispatcher] oc.statusEndpoint = ${config.oc.statusEndpoint}`);
console.log(`[${ts()}] [dispatcher] oc.minAvailable = ${config.oc.minAvailable}`);
console.log(`[${ts()}] [dispatcher] intervals.watcherIdle = ${config.intervals.watcherIdle}ms`);
console.log(`[${ts()}] [dispatcher] intervals.watcherActive = ${config.intervals.watcherActive}ms`);
console.log(`[${ts()}] [dispatcher] intervals.schedulerIdle = ${config.intervals.schedulerIdle}ms`);
console.log(`[${ts()}] [dispatcher] intervals.schedulerActive= ${config.intervals.schedulerActive}ms`);
console.log(`[${ts()}] [dispatcher] intervals.cooldownAfterPush = ${config.intervals.cooldownAfterPush}ms`);
// Shared pending queue — Watcher writes, Scheduler reads + clears
const pending: Map<string, PendingEntry> = new Map();
const watcher = new ProjectionWatcher(config, pending);
const scheduler = new OcScheduler(config, pending);
// Start both loops independently
watcher.start();
scheduler.start();
console.log(`[${ts()}] [dispatcher] both loops running. Press Ctrl+C to stop.`);
// Graceful shutdown
const shutdown = (): void => {
console.log(`\n[${ts()}] [dispatcher] shutting down...`);
watcher.stop();
scheduler.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Keep the process alive (the timers are enough, but be explicit)
await new Promise<void>(() => {
// never resolves — loops keep running via setTimeout chains
});
}
main().catch((err: unknown) => {
console.error(`[${new Date().toISOString()}] [dispatcher] fatal: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});