@triflux/core 10.50.2 → 10.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/hooks/agy-session-hook.mjs +11 -56
  2. package/hooks/codex-session-hook.mjs +11 -101
  3. package/hub/bridge.mjs +1 -79
  4. package/hub/cli-adapter-base.mjs +12 -90
  5. package/hub/codex-adapter.mjs +0 -1
  6. package/hub/codex-preflight.mjs +5 -28
  7. package/hub/index.mjs +1 -1
  8. package/hub/lib/known-errors.json +2 -2
  9. package/hub/lib/process-utils.mjs +0 -123
  10. package/hub/lib/spawn-trace.mjs +3 -2
  11. package/hub/lib/timeout-defaults.mjs +0 -1
  12. package/hub/role-contract.mjs +0 -2
  13. package/hud/cli-policy.mjs +0 -22
  14. package/hud/constants.mjs +11 -26
  15. package/hud/hud-qos-status.mjs +23 -51
  16. package/hud/providers/gemini.mjs +105 -20
  17. package/hud/renderers.mjs +50 -104
  18. package/hud/utils.mjs +2 -2
  19. package/package.json +1 -3
  20. package/scripts/lib/agent-route-policy.mjs +162 -14
  21. package/scripts/lib/claudemd-scanner.mjs +2 -199
  22. package/scripts/lib/cli-claude.mjs +0 -2
  23. package/scripts/lib/context.mjs +3 -19
  24. package/scripts/lib/env-probe.mjs +5 -64
  25. package/scripts/lib/gemini-profiles.mjs +2 -69
  26. package/scripts/lib/machine-profile.mjs +3 -17
  27. package/scripts/lib/mcp-filter.mjs +109 -53
  28. package/scripts/lib/mcp-health.mjs +2 -2
  29. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  30. package/scripts/lib/psmux-info.mjs +11 -59
  31. package/scripts/lib/session-presence.mjs +1 -1
  32. package/scripts/lib/skill-template.mjs +0 -245
  33. package/hub/dynamic-routing-engine.mjs +0 -511
  34. package/hub/lib/risk-tier.mjs +0 -53
  35. package/hub/lib/ssh-command.mjs +0 -231
  36. package/hub/lib/staged-file-detect.mjs +0 -43
  37. package/hub/lib/tfx-route-args.mjs +0 -353
  38. package/hub/routing-snapshot.mjs +0 -261
  39. package/mesh/index.mjs +0 -69
  40. package/mesh/mesh-budget.mjs +0 -137
  41. package/mesh/mesh-heartbeat.mjs +0 -100
  42. package/mesh/mesh-protocol.mjs +0 -96
  43. package/mesh/mesh-queue.mjs +0 -163
  44. package/mesh/mesh-registry.mjs +0 -78
  45. package/mesh/mesh-router.mjs +0 -77
  46. package/scripts/lib/dynamic-route-cli.mjs +0 -107
@@ -1,261 +0,0 @@
1
- // hub/routing-snapshot.mjs
2
- //
3
- // PRD: .omx/plans/prd-dynamic-routing-engine.md
4
- //
5
- // Phase 0 — pure read-only context projector. Wraps HUD providers and the
6
- // AccountBroker public snapshot into a single deterministic snapshot that
7
- // `evaluateDynamicRoute(request, snapshot, policy)` consumes.
8
- //
9
- // Phase 0 contract:
10
- // - no callers yet (dormant). Only `evaluateDynamicRoute()` is meant to read this.
11
- // - lazy import of HUD providers + broker so this module imports cleanly even
12
- // when those surfaces are absent in tests.
13
- // - never mutates upstream state — strictly read-only.
14
- // - all output fields are derivable from `publicSnapshot()` / HUD redacted API,
15
- // so writing the resulting snapshot to disk does not leak credentials.
16
-
17
- const CLAUDE_HUD_MODULE = "../hud/providers/claude.mjs";
18
- const CODEX_HUD_MODULE = "../hud/providers/codex.mjs";
19
- const BROKER_MODULE = "./account-broker.mjs";
20
-
21
- async function loadHudClaude() {
22
- try {
23
- return await import(CLAUDE_HUD_MODULE);
24
- } catch {
25
- return null;
26
- }
27
- }
28
-
29
- async function loadHudCodex() {
30
- try {
31
- return await import(CODEX_HUD_MODULE);
32
- } catch {
33
- return null;
34
- }
35
- }
36
-
37
- async function loadBroker() {
38
- try {
39
- const mod = await import(BROKER_MODULE);
40
- return mod.broker ?? mod.default ?? null;
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- function normalizeClaudeUsage(raw, now) {
47
- if (!raw || typeof raw !== "object") return null;
48
- const data = raw.data ?? raw;
49
- if (!data) return null;
50
- return {
51
- fiveHourPercent: numericOrNull(data.fiveHourPercent),
52
- weeklyPercent: numericOrNull(data.weeklyPercent),
53
- fiveHourResetsAt: toEpoch(data.fiveHourResetsAt),
54
- weeklyResetsAt: toEpoch(data.weeklyResetsAt),
55
- observedAt: toEpoch(data.observedAt) ?? now,
56
- isStale: Boolean(raw.isStale ?? data.isStale),
57
- source: data.source ?? "hud/providers/claude.mjs",
58
- };
59
- }
60
-
61
- function normalizeCodexUsage(raw, now) {
62
- if (!raw || typeof raw !== "object") return null;
63
- const buckets = raw.buckets ?? raw.data?.buckets ?? raw;
64
- if (!buckets || typeof buckets !== "object") return null;
65
- const entries = Object.entries(buckets);
66
- if (entries.length === 0) return null;
67
- const out = {};
68
- for (const [id, bucket] of entries) {
69
- if (!bucket || typeof bucket !== "object") continue;
70
- out[id] = {
71
- primary: normalizeCodexBucketLeg(bucket.primary),
72
- secondary: normalizeCodexBucketLeg(bucket.secondary),
73
- };
74
- }
75
- return {
76
- buckets: out,
77
- observedAt: toEpoch(raw.observedAt) ?? now,
78
- isStale: Boolean(raw.shouldRefresh === false ? false : raw.isStale),
79
- source: raw.source ?? "hud/providers/codex.mjs",
80
- };
81
- }
82
-
83
- function normalizeCodexBucketLeg(leg) {
84
- if (!leg || typeof leg !== "object") return null;
85
- return {
86
- usedPercent: numericOrNull(leg.used_percent ?? leg.usedPercent),
87
- resetsAt: toEpoch(leg.resets_at ?? leg.resetsAt),
88
- };
89
- }
90
-
91
- function normalizeBrokerSnapshot(raw) {
92
- if (!Array.isArray(raw)) return null;
93
- const grouped = { codex: [], claude: [], gemini: [] };
94
- for (const entry of raw) {
95
- if (!entry || typeof entry !== "object") continue;
96
- const provider = entry.provider;
97
- if (!grouped[provider]) continue;
98
- grouped[provider].push({
99
- id: entry.id,
100
- tier: entry.tier ?? "unknown",
101
- busy: Boolean(entry.busy),
102
- cooldownUntil: toEpoch(entry.cooldownUntil),
103
- lastUsedAt: toEpoch(entry.lastUsedAt),
104
- remainingMs: numericOrNull(entry.remainingMs),
105
- circuitState: entry.circuitState ?? "closed",
106
- failureCount: numericOrNull(entry.failureCount) ?? 0,
107
- expiresAt: toEpoch(entry.expiresAt),
108
- drainAfter: toEpoch(entry.drainAfter),
109
- });
110
- }
111
- return grouped;
112
- }
113
-
114
- function numericOrNull(v) {
115
- if (v === null || v === undefined) return null;
116
- const n = Number(v);
117
- return Number.isFinite(n) ? n : null;
118
- }
119
-
120
- function toEpoch(v) {
121
- if (v === null || v === undefined) return null;
122
- if (typeof v === "number") return Number.isFinite(v) ? v : null;
123
- const parsed = Date.parse(v);
124
- return Number.isNaN(parsed) ? null : parsed;
125
- }
126
-
127
- export async function buildRoutingSnapshot({
128
- forceRefresh = false,
129
- now = Date.now(),
130
- } = {}) {
131
- const [hudClaude, hudCodex, broker] = await Promise.all([
132
- loadHudClaude(),
133
- loadHudCodex(),
134
- loadBroker(),
135
- ]);
136
-
137
- let claude = null;
138
- if (hudClaude?.readClaudeUsageSnapshot) {
139
- try {
140
- claude = normalizeClaudeUsage(
141
- hudClaude.readClaudeUsageSnapshot({ forceRefresh }),
142
- now,
143
- );
144
- } catch (err) {
145
- claude = {
146
- error: String(err?.message ?? err),
147
- observedAt: now,
148
- isStale: true,
149
- };
150
- }
151
- }
152
-
153
- let codex = null;
154
- if (hudCodex?.readCodexRateLimitSnapshot) {
155
- try {
156
- codex = normalizeCodexUsage(
157
- hudCodex.readCodexRateLimitSnapshot({ forceRefresh }),
158
- now,
159
- );
160
- } catch (err) {
161
- codex = {
162
- error: String(err?.message ?? err),
163
- observedAt: now,
164
- isStale: true,
165
- };
166
- }
167
- }
168
-
169
- let brokerState = null;
170
- if (broker?.publicSnapshot) {
171
- try {
172
- brokerState = normalizeBrokerSnapshot(broker.publicSnapshot());
173
- } catch (err) {
174
- brokerState = { error: String(err?.message ?? err) };
175
- }
176
- }
177
-
178
- return {
179
- schemaVersion: 1,
180
- observedAt: now,
181
- claude,
182
- codex,
183
- broker: brokerState,
184
- };
185
- }
186
-
187
- // Phase 1 — in-process snapshot cache.
188
- // 의도: caller (Phase 1+ conductor / swarm-hypervisor) 가 spawn loop 에서
189
- // 매번 HUD/broker IO 를 트리거하지 않도록 정해진 TTL 안에서 같은 snapshot
190
- // 을 재사용한다. cache 는 모듈-local 이라 process 단위 (테스트 격리 위해
191
- // `resetSnapshotCache()` 노출).
192
-
193
- const DEFAULT_CACHE_TTL_MS = 900_000; // policy freshness max 와 정합
194
-
195
- let _cache = null; // { snapshot, fetchedAt }
196
-
197
- export function resetSnapshotCache() {
198
- _cache = null;
199
- }
200
-
201
- /**
202
- * Cached snapshot shortcut. cache TTL 안이면 재사용, 아니면 새로 빌드.
203
- *
204
- * @param {{
205
- * forceRefresh?: boolean, // true 면 cache 무시하고 새로 빌드
206
- * maxAgeMs?: number, // override cache TTL (default 900_000)
207
- * now?: number, // pure-fn: caller 가 epoch 주입 (default Date.now())
208
- * builder?: function, // 테스트용 builder 주입 (default buildRoutingSnapshot)
209
- * }} [opts]
210
- * @returns {Promise<object>}
211
- */
212
- export async function getDefaultSnapshot(opts = {}) {
213
- const now = typeof opts.now === "number" ? opts.now : Date.now();
214
- const maxAgeMs =
215
- typeof opts.maxAgeMs === "number" ? opts.maxAgeMs : DEFAULT_CACHE_TTL_MS;
216
- const builder = opts.builder || buildRoutingSnapshot;
217
- const forceRefresh = Boolean(opts.forceRefresh);
218
-
219
- if (!forceRefresh && _cache && now - _cache.fetchedAt < maxAgeMs) {
220
- return _cache.snapshot;
221
- }
222
-
223
- const snapshot = await builder({ forceRefresh, now });
224
- _cache = { snapshot, fetchedAt: now };
225
- return snapshot;
226
- }
227
-
228
- /**
229
- * Synchronous cache shortcut. cache TTL 안에 fresh snapshot이 있으면 즉시 반환,
230
- * 아니면 null. caller (sync hot path — e.g. conductor.spawnSession) 가 broker.lease
231
- * atomic 인변량을 깨지 않고 dynamic routing decision 을 sync 로 평가할 수 있도록 한다.
232
- * builder 호출(IO)은 하지 않는다 — cache miss 면 caller 가 fallback decision 사용.
233
- *
234
- * @param {{
235
- * maxAgeMs?: number, // override cache TTL (default 900_000)
236
- * now?: number, // pure-fn: caller 가 epoch 주입 (default Date.now())
237
- * }} [opts]
238
- * @returns {object|null} cache fresh이면 snapshot, 아니면 null
239
- */
240
- export function tryGetCachedSnapshot(opts = {}) {
241
- const now = typeof opts.now === "number" ? opts.now : Date.now();
242
- const maxAgeMs =
243
- typeof opts.maxAgeMs === "number" ? opts.maxAgeMs : DEFAULT_CACHE_TTL_MS;
244
- if (_cache && now - _cache.fetchedAt < maxAgeMs) {
245
- return _cache.snapshot;
246
- }
247
- return null;
248
- }
249
-
250
- // Test helpers — pure normalizers exposed so unit tests can build snapshots
251
- // from mock provider responses without running real HUD I/O.
252
- export const __internal__ = {
253
- normalizeClaudeUsage,
254
- normalizeCodexUsage,
255
- normalizeBrokerSnapshot,
256
- // Phase 1 cache 테스트 helpers
257
- getCacheStateForTest: () => (_cache ? { ..._cache } : null),
258
- setCacheForTest: (state) => {
259
- _cache = state ? { ...state } : null;
260
- },
261
- };
package/mesh/index.mjs DELETED
@@ -1,69 +0,0 @@
1
- import { readdirSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
- export { createMeshBudget } from "./mesh-budget.mjs";
5
- export { createHeartbeatMonitor } from "./mesh-heartbeat.mjs";
6
- export {
7
- createMessage,
8
- deserialize,
9
- MSG_TYPES,
10
- serialize,
11
- validate,
12
- } from "./mesh-protocol.mjs";
13
- export { createMessageQueue } from "./mesh-queue.mjs";
14
- export { createRegistry } from "./mesh-registry.mjs";
15
- export { routeMessage, routeOrDeadLetter } from "./mesh-router.mjs";
16
-
17
- /**
18
- * Loads skills assigned to a specific agent from a skills directory.
19
- * Reuses the same directory-scan approach as generateSkillDocs().
20
- *
21
- * @param {string} agentId - The agent identifier
22
- * @param {string} skillsDir - Path to the skills directory
23
- * @returns {Promise<string[]>} Array of skill names available to this agent
24
- */
25
- export async function loadSkillsForAgent(agentId, skillsDir) {
26
- if (!agentId || typeof agentId !== "string") {
27
- throw new TypeError("agentId must be a non-empty string");
28
- }
29
- if (!skillsDir || typeof skillsDir !== "string") {
30
- throw new TypeError("skillsDir must be a non-empty string");
31
- }
32
-
33
- let entries;
34
- try {
35
- entries = readdirSync(skillsDir, { withFileTypes: true });
36
- } catch {
37
- return [];
38
- }
39
-
40
- const skills = [];
41
- for (const entry of entries) {
42
- if (!entry.isDirectory()) continue;
43
- const skillName = entry.name;
44
- const skillPath = join(skillsDir, skillName, "SKILL.md");
45
- let skillContent = null;
46
- try {
47
- const { readFileSync } = await import("node:fs");
48
- skillContent = readFileSync(skillPath, "utf8");
49
- } catch {
50
- // Skill has no SKILL.md — include it anyway
51
- }
52
-
53
- // If SKILL.md mentions the agentId or no agent restriction, include it
54
- const isRestricted = skillContent
55
- ? /^agents?\s*:/im.test(skillContent)
56
- : false;
57
-
58
- if (!isRestricted) {
59
- skills.push(skillName);
60
- continue;
61
- }
62
-
63
- if (skillContent?.includes(agentId)) {
64
- skills.push(skillName);
65
- }
66
- }
67
-
68
- return skills;
69
- }
@@ -1,137 +0,0 @@
1
- /**
2
- * Warning level thresholds mirroring context-monitor.mjs classifyContextThreshold().
3
- * Reproduced here to avoid cross-module dependency.
4
- */
5
- const WARNING_LEVELS = Object.freeze({
6
- critical: 90,
7
- warn: 80,
8
- info: 60,
9
- ok: 0,
10
- });
11
-
12
- /**
13
- * Clamps a percentage value to [0, 100].
14
- * @param {number} value
15
- * @returns {number}
16
- */
17
- function clampPercent(value) {
18
- const n = Number(value);
19
- if (!Number.isFinite(n)) return 0;
20
- return Math.min(100, Math.max(0, n));
21
- }
22
-
23
- /**
24
- * Classifies a usage percentage into a warning level.
25
- * Mirrors context-monitor.mjs classifyContextThreshold().
26
- * @param {number} percent
27
- * @returns {{ level: string, message: string }}
28
- */
29
- function classifyLevel(percent) {
30
- const p = clampPercent(percent);
31
- if (p >= WARNING_LEVELS.critical)
32
- return { level: "critical", message: "에이전트 분할 또는 세션 교체 권장" };
33
- if (p >= WARNING_LEVELS.warn) return { level: "warn", message: "압축 권장" };
34
- if (p >= WARNING_LEVELS.info)
35
- return { level: "info", message: "컨텍스트 절반 이상 사용" };
36
- return { level: "ok", message: "" };
37
- }
38
-
39
- /**
40
- * Creates a per-agent token budget manager.
41
- * @returns {object} Budget API
42
- */
43
- export function createMeshBudget() {
44
- // Map<agentId, { allocated: number, consumed: number }>
45
- const budgets = new Map();
46
-
47
- /**
48
- * Allocates a token budget to an agent.
49
- * @param {string} agentId
50
- * @param {number} tokenLimit
51
- */
52
- function allocate(agentId, tokenLimit) {
53
- if (!agentId || typeof agentId !== "string") {
54
- throw new TypeError("agentId must be a non-empty string");
55
- }
56
- const limit = Math.max(0, Math.round(Number(tokenLimit) || 0));
57
- const existing = budgets.get(agentId);
58
- budgets.set(agentId, {
59
- allocated: limit,
60
- consumed: existing?.consumed ?? 0,
61
- });
62
- }
63
-
64
- /**
65
- * Records token consumption for an agent.
66
- * @param {string} agentId
67
- * @param {number} tokens
68
- * @returns {{ remaining: number, percent: number, level: string }}
69
- */
70
- function consume(agentId, tokens) {
71
- const budget = budgets.get(agentId);
72
- if (!budget) {
73
- throw new Error(`No budget allocated for agent: ${agentId}`);
74
- }
75
- const amount = Math.max(0, Math.round(Number(tokens) || 0));
76
- const updated = {
77
- allocated: budget.allocated,
78
- consumed: budget.consumed + amount,
79
- };
80
- budgets.set(agentId, updated);
81
-
82
- const remaining = Math.max(0, updated.allocated - updated.consumed);
83
- const percent =
84
- updated.allocated > 0
85
- ? clampPercent((updated.consumed / updated.allocated) * 100)
86
- : 100;
87
- const { level } = classifyLevel(percent);
88
- return { remaining, percent, level };
89
- }
90
-
91
- /**
92
- * Returns the budget status for an agent.
93
- * @param {string} agentId
94
- * @returns {{ allocated: number, consumed: number, remaining: number, level: string }}
95
- */
96
- function getStatus(agentId) {
97
- const budget = budgets.get(agentId);
98
- if (!budget) {
99
- return { allocated: 0, consumed: 0, remaining: 0, level: "ok" };
100
- }
101
- const remaining = Math.max(0, budget.allocated - budget.consumed);
102
- const percent =
103
- budget.allocated > 0
104
- ? clampPercent((budget.consumed / budget.allocated) * 100)
105
- : 0;
106
- const { level } = classifyLevel(percent);
107
- return {
108
- allocated: budget.allocated,
109
- consumed: budget.consumed,
110
- remaining,
111
- level,
112
- };
113
- }
114
-
115
- /**
116
- * Resets consumed tokens for all agents (keeps allocations).
117
- */
118
- function resetAll() {
119
- for (const [agentId, budget] of budgets) {
120
- budgets.set(agentId, { allocated: budget.allocated, consumed: 0 });
121
- }
122
- }
123
-
124
- /**
125
- * Returns a snapshot of all current allocations.
126
- * @returns {Map<string, { allocated: number, consumed: number }>}
127
- */
128
- function listAllocations() {
129
- const snap = new Map();
130
- for (const [id, b] of budgets) {
131
- snap.set(id, Object.freeze({ ...b }));
132
- }
133
- return snap;
134
- }
135
-
136
- return { allocate, consume, getStatus, resetAll, listAllocations };
137
- }
@@ -1,100 +0,0 @@
1
- const DEFAULT_INTERVAL_MS = 30_000;
2
- const DEFAULT_THRESHOLD_MS = 60_000;
3
-
4
- /**
5
- * Creates a heartbeat monitor that tracks agent liveness.
6
- *
7
- * @param {object} registry - A mesh-registry instance
8
- * @param {object} [opts]
9
- * @param {number} [opts.intervalMs=30000] - Scan interval
10
- * @param {number} [opts.thresholdMs=60000] - Stale threshold
11
- * @param {function} [opts.onStale] - Called with agentId when stale detected
12
- * @returns {object} HeartbeatMonitor API
13
- */
14
- export function createHeartbeatMonitor(registry, opts = {}) {
15
- const {
16
- intervalMs = DEFAULT_INTERVAL_MS,
17
- thresholdMs = DEFAULT_THRESHOLD_MS,
18
- onStale,
19
- } = opts;
20
-
21
- /** @type {Map<string, number>} agentId → last heartbeat timestamp */
22
- const heartbeats = new Map();
23
- let timer = null;
24
-
25
- /**
26
- * Records a heartbeat for an agent.
27
- * @param {string} agentId
28
- */
29
- function recordHeartbeat(agentId) {
30
- if (!agentId || typeof agentId !== "string") {
31
- throw new TypeError("agentId must be a non-empty string");
32
- }
33
- heartbeats.set(agentId, Date.now());
34
- }
35
-
36
- /**
37
- * Returns agent IDs whose last heartbeat exceeds the threshold.
38
- * Only considers agents currently registered in the registry.
39
- *
40
- * @param {number} [customThresholdMs] - Override default threshold
41
- * @returns {string[]}
42
- */
43
- function getStaleAgents(customThresholdMs) {
44
- const threshold = customThresholdMs ?? thresholdMs;
45
- const now = Date.now();
46
- const registered = registry.listAll();
47
- const stale = [];
48
-
49
- for (const agent of registered) {
50
- const lastBeat = heartbeats.get(agent.agentId);
51
- if (lastBeat === undefined || now - lastBeat >= threshold) {
52
- stale.push(agent.agentId);
53
- }
54
- }
55
- return stale;
56
- }
57
-
58
- /**
59
- * Runs a single scan: finds stale agents and invokes onStale callback.
60
- */
61
- function scan() {
62
- const stale = getStaleAgents();
63
- if (typeof onStale === "function") {
64
- for (const agentId of stale) {
65
- onStale(agentId);
66
- }
67
- }
68
- }
69
-
70
- /**
71
- * Starts periodic heartbeat scanning.
72
- * @param {number} [customIntervalMs]
73
- */
74
- function start(customIntervalMs) {
75
- stop();
76
- const interval = customIntervalMs ?? intervalMs;
77
- timer = setInterval(scan, interval);
78
- timer.unref?.();
79
- }
80
-
81
- /**
82
- * Stops periodic scanning.
83
- */
84
- function stop() {
85
- if (timer !== null) {
86
- clearInterval(timer);
87
- timer = null;
88
- }
89
- }
90
-
91
- /**
92
- * Removes heartbeat record for an agent.
93
- * @param {string} agentId
94
- */
95
- function remove(agentId) {
96
- heartbeats.delete(agentId);
97
- }
98
-
99
- return { recordHeartbeat, getStaleAgents, start, stop, scan, remove };
100
- }
@@ -1,96 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
-
3
- export const MSG_TYPES = Object.freeze({
4
- REQUEST: "request",
5
- RESPONSE: "response",
6
- EVENT: "event",
7
- HEARTBEAT: "heartbeat",
8
- });
9
-
10
- const VALID_TYPES = new Set(Object.values(MSG_TYPES));
11
-
12
- /**
13
- * Creates an immutable mesh message.
14
- * @param {string} type - One of MSG_TYPES values
15
- * @param {string} from - Sender agent ID
16
- * @param {string} to - Recipient agent ID (or "*" for broadcast)
17
- * @param {unknown} payload - Message payload
18
- * @returns {Readonly<object>}
19
- */
20
- export function createMessage(type, from, to, payload = null) {
21
- if (!VALID_TYPES.has(type)) {
22
- throw new TypeError(`Invalid message type: ${type}`);
23
- }
24
- if (!from || typeof from !== "string") {
25
- throw new TypeError("from must be a non-empty string");
26
- }
27
- if (!to || typeof to !== "string") {
28
- throw new TypeError("to must be a non-empty string");
29
- }
30
- return Object.freeze({
31
- type,
32
- from,
33
- to,
34
- payload,
35
- timestamp: new Date().toISOString(),
36
- correlationId: randomUUID(),
37
- });
38
- }
39
-
40
- /**
41
- * Serializes a message to a JSON string.
42
- * @param {object} message
43
- * @returns {string}
44
- */
45
- export function serialize(message) {
46
- return JSON.stringify(message);
47
- }
48
-
49
- /**
50
- * Deserializes a JSON string to a message object.
51
- * @param {string} raw
52
- * @returns {object}
53
- */
54
- export function deserialize(raw) {
55
- if (typeof raw !== "string") {
56
- throw new TypeError("raw must be a string");
57
- }
58
- let parsed;
59
- try {
60
- parsed = JSON.parse(raw);
61
- } catch {
62
- throw new SyntaxError(`Failed to parse message: ${raw}`);
63
- }
64
- return parsed;
65
- }
66
-
67
- /**
68
- * Validates a message object.
69
- * @param {unknown} message
70
- * @returns {{ valid: boolean, errors: string[] }}
71
- */
72
- export function validate(message) {
73
- const errors = [];
74
-
75
- if (!message || typeof message !== "object") {
76
- return { valid: false, errors: ["message must be an object"] };
77
- }
78
-
79
- if (!VALID_TYPES.has(message.type)) {
80
- errors.push(`Invalid type: ${message.type}`);
81
- }
82
- if (!message.from || typeof message.from !== "string") {
83
- errors.push("from must be a non-empty string");
84
- }
85
- if (!message.to || typeof message.to !== "string") {
86
- errors.push("to must be a non-empty string");
87
- }
88
- if (!message.timestamp || typeof message.timestamp !== "string") {
89
- errors.push("timestamp must be a non-empty string");
90
- }
91
- if (!message.correlationId || typeof message.correlationId !== "string") {
92
- errors.push("correlationId must be a non-empty string");
93
- }
94
-
95
- return { valid: errors.length === 0, errors };
96
- }