* 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>
73 lines
2.4 KiB
TypeScript
73 lines
2.4 KiB
TypeScript
// Tests for scheduler cooldown logic
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// ── minimal cooldown state machine ───────────────────────────────────────────
|
|
// Mirrors the cooldown guard in OcScheduler.poll() so we can test it in isolation.
|
|
|
|
function makeCooldownGuard(cooldownMs: number) {
|
|
let lastPushAt = 0;
|
|
|
|
return {
|
|
recordPush() {
|
|
lastPushAt = Date.now();
|
|
},
|
|
isInCooldown(): boolean {
|
|
if (lastPushAt === 0) return false;
|
|
return Date.now() - lastPushAt < cooldownMs;
|
|
},
|
|
remainingMs(): number {
|
|
if (lastPushAt === 0) return 0;
|
|
const elapsed = Date.now() - lastPushAt;
|
|
return Math.max(0, cooldownMs - elapsed);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('scheduler cooldown logic', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('not in cooldown before any push', () => {
|
|
const guard = makeCooldownGuard(60_000);
|
|
expect(guard.isInCooldown()).toBe(false);
|
|
});
|
|
|
|
it('enters cooldown immediately after a push', () => {
|
|
const guard = makeCooldownGuard(60_000);
|
|
guard.recordPush();
|
|
expect(guard.isInCooldown()).toBe(true);
|
|
});
|
|
|
|
it('remains in cooldown within the window', () => {
|
|
const guard = makeCooldownGuard(60_000);
|
|
guard.recordPush();
|
|
vi.advanceTimersByTime(59_999); // just under 60 s
|
|
expect(guard.isInCooldown()).toBe(true);
|
|
expect(guard.remainingMs()).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('exits cooldown after the window expires', () => {
|
|
const guard = makeCooldownGuard(60_000);
|
|
guard.recordPush();
|
|
vi.advanceTimersByTime(60_001); // just over 60 s
|
|
expect(guard.isInCooldown()).toBe(false);
|
|
expect(guard.remainingMs()).toBe(0);
|
|
});
|
|
|
|
it('resets cooldown on a second push', () => {
|
|
const guard = makeCooldownGuard(60_000);
|
|
guard.recordPush();
|
|
vi.advanceTimersByTime(30_000); // 30 s into first cooldown
|
|
guard.recordPush(); // second push resets the clock
|
|
vi.advanceTimersByTime(30_000); // only 30 s since second push
|
|
expect(guard.isInCooldown()).toBe(true); // still cooling down
|
|
});
|
|
});
|