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

96 lines
3.7 KiB
TypeScript

// Tests for config deepMerge logic
import { describe, it, expect } from 'vitest';
import type { DispatcherConfig } from '../src/types.js';
// ── inline copy of deepMerge (same logic as config.ts) ───────────────────────
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
const result = { ...base };
for (const key of Object.keys(override) as (keyof T)[]) {
const overrideVal = override[key];
const baseVal = base[key];
if (
overrideVal !== null &&
typeof overrideVal === 'object' &&
!Array.isArray(overrideVal) &&
baseVal !== null &&
typeof baseVal === 'object' &&
!Array.isArray(baseVal)
) {
result[key] = deepMerge(baseVal as object, overrideVal as object) as T[keyof T];
} else if (overrideVal !== undefined) {
result[key] = overrideVal as T[keyof T];
}
}
return result;
}
// ── helpers ───────────────────────────────────────────────────────────────────
const DEFAULTS: DispatcherConfig = {
ograph: {
endpoint: 'https://ograph.example.com',
token: undefined,
projections: [],
},
oc: {
statusEndpoint: 'http://localhost:18789/status',
statusToken: 'default-token',
minAvailable: 2,
},
intervals: {
watcherIdle: 30_000,
watcherActive: 5_000,
schedulerIdle: 60_000,
schedulerActive: 5_000,
cooldownAfterPush: 60_000,
},
};
// ── tests ─────────────────────────────────────────────────────────────────────
describe('config deepMerge logic', () => {
it('returns defaults unchanged when no overrides supplied', () => {
const result = deepMerge(DEFAULTS, {});
expect(result).toEqual(DEFAULTS);
});
it('overrides a scalar field without touching siblings', () => {
const result = deepMerge(DEFAULTS, {
ograph: { endpoint: 'https://custom.example.com', token: undefined, projections: [] },
});
expect(result.ograph.endpoint).toBe('https://custom.example.com');
expect(result.oc.statusToken).toBe('default-token'); // unchanged
});
it('deep-merges nested objects — partial override preserves untouched sibling keys', () => {
const result = deepMerge(DEFAULTS, {
oc: { statusToken: 'my-token' } as DispatcherConfig['oc'],
});
expect(result.oc.statusToken).toBe('my-token');
expect(result.oc.statusEndpoint).toBe(DEFAULTS.oc.statusEndpoint); // preserved
expect(result.oc.minAvailable).toBe(2); // preserved
});
it('env var override merges on top of file config', () => {
const fileConfig: Partial<DispatcherConfig> = {
ograph: { endpoint: 'https://file-endpoint.example.com', token: undefined, projections: ['p1'] },
};
let config = deepMerge(DEFAULTS, fileConfig);
// Simulate env var override (as done in loadConfig)
config.ograph.endpoint = 'https://env-endpoint.example.com';
expect(config.ograph.endpoint).toBe('https://env-endpoint.example.com');
expect(config.ograph.projections).toEqual(['p1']); // file value preserved
expect(config.oc.minAvailable).toBe(2); // default preserved
});
it('does not overwrite a field when override value is undefined', () => {
const result = deepMerge(DEFAULTS, {
ograph: { endpoint: undefined as unknown as string, token: undefined, projections: [] },
});
// endpoint should keep the default since override is undefined
expect(result.ograph.endpoint).toBe(DEFAULTS.ograph.endpoint);
});
});