* 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>
96 lines
3.7 KiB
TypeScript
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);
|
|
});
|
|
});
|