@triflux/core 10.50.2 → 10.52.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.
- package/hooks/agy-session-hook.mjs +11 -56
- package/hooks/codex-session-hook.mjs +11 -101
- package/hub/bridge.mjs +65 -1250
- package/hub/cli-adapter-base.mjs +12 -90
- package/hub/codex-adapter.mjs +0 -1
- package/hub/codex-preflight.mjs +5 -28
- package/hub/index.mjs +3 -19
- package/hub/lib/process-utils.mjs +1 -156
- package/hub/lib/spawn-trace.mjs +3 -2
- package/hub/lib/timeout-defaults.mjs +0 -1
- package/hub/team/claude-daemon-control.mjs +1 -2
- package/hub/team/claude-session-projection.mjs +5 -73
- package/hub/team/nativeProxy.mjs +759 -0
- package/hub/team/session-context.mjs +1 -1
- package/hud/cli-policy.mjs +0 -22
- package/hud/constants.mjs +13 -26
- package/hud/hud-qos-status.mjs +42 -54
- package/hud/providers/gemini.mjs +125 -20
- package/hud/renderers.mjs +101 -122
- package/hud/utils.mjs +10 -6
- package/package.json +2 -6
- package/scripts/lib/agent-route-policy.mjs +143 -62
- package/scripts/lib/cli-claude.mjs +0 -14
- package/scripts/lib/env-probe.mjs +2 -131
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-guard-engine.mjs +40 -98
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +11 -59
- package/scripts/lib/session-presence.mjs +2 -100
- package/scripts/lib/skill-template.mjs +0 -245
- package/hub/adaptive-diagnostic.mjs +0 -350
- package/hub/adaptive-inject.mjs +0 -224
- package/hub/adaptive-memory.mjs +0 -277
- package/hub/adaptive.mjs +0 -172
- package/hub/assign-callbacks.mjs +0 -170
- package/hub/delegator/contracts.mjs +0 -38
- package/hub/delegator/index.mjs +0 -14
- package/hub/delegator/schema/delegator-tools.schema.json +0 -250
- package/hub/delegator/service.mjs +0 -336
- package/hub/delegator/tool-definitions.mjs +0 -35
- package/hub/dynamic-routing-engine.mjs +0 -511
- package/hub/hitl.mjs +0 -202
- package/hub/lib/known-errors.json +0 -72
- package/hub/lib/memory-store.mjs +0 -1001
- package/hub/lib/phase-manager.mjs +0 -493
- package/hub/lib/risk-tier.mjs +0 -53
- package/hub/lib/ssh-command.mjs +0 -231
- package/hub/lib/staged-file-detect.mjs +0 -43
- package/hub/lib/state-snapshot.mjs +0 -293
- package/hub/lib/tfx-route-args.mjs +0 -353
- package/hub/lib/trace-recorder.mjs +0 -153
- package/hub/lib/uuidv7.mjs +0 -45
- package/hub/memory-doctor.mjs +0 -640
- package/hub/middleware/quota-middleware.mjs +0 -70
- package/hub/middleware/request-logger.mjs +0 -191
- package/hub/paths.mjs +0 -24
- package/hub/pipeline/index.mjs +0 -163
- package/hub/pipeline/state.mjs +0 -191
- package/hub/pipeline/transitions.mjs +0 -131
- package/hub/reflexion.mjs +0 -290
- package/hub/role-contract.mjs +0 -314
- package/hub/router.mjs +0 -2026
- package/hub/routing-snapshot.mjs +0 -261
- package/hub/session-fingerprint.mjs +0 -418
- package/hub/state.mjs +0 -300
- package/hub/team/synapse-http.mjs +0 -311
- package/hub/team-bridge.mjs +0 -27
- package/mesh/index.mjs +0 -69
- package/mesh/mesh-budget.mjs +0 -137
- package/mesh/mesh-heartbeat.mjs +0 -100
- package/mesh/mesh-protocol.mjs +0 -96
- package/mesh/mesh-queue.mjs +0 -163
- package/mesh/mesh-registry.mjs +0 -78
- package/mesh/mesh-router.mjs +0 -77
- package/scripts/lib/claudemd-scanner.mjs +0 -223
- package/scripts/lib/context.mjs +0 -67
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
- package/scripts/lib/handoff.mjs +0 -187
- package/scripts/lib/logger.mjs +0 -122
- package/scripts/lib/stealth-fetch.mjs +0 -176
|
@@ -1,511 +0,0 @@
|
|
|
1
|
-
// hub/dynamic-routing-engine.mjs
|
|
2
|
-
//
|
|
3
|
-
// PRD: .omx/plans/prd-dynamic-routing-engine.md
|
|
4
|
-
//
|
|
5
|
-
// Phase 0 — pure deterministic classifier. Takes a `request`, a `snapshot`
|
|
6
|
-
// (from `hub/routing-snapshot.mjs`), and a `policy` (loaded from
|
|
7
|
-
// `config/routing-policy.json`), then emits a single `RouteDecision`.
|
|
8
|
-
//
|
|
9
|
-
// Phase 0 contract:
|
|
10
|
-
// - no callers yet (dormant). conductor/swarm/tfx-route.sh integration is Phase 1+.
|
|
11
|
-
// - pure function: same (request, snapshot, policy) → same RouteDecision.
|
|
12
|
-
// - no network, no disk reads, no clock reads outside the explicit `now` arg.
|
|
13
|
-
// - fallback policy = codex-default. fails closed.
|
|
14
|
-
// - scenario evaluators are first-match-wins in policy.scenarios order.
|
|
15
|
-
|
|
16
|
-
import { createHash } from "node:crypto";
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
18
|
-
import { dirname, join } from "node:path";
|
|
19
|
-
import { fileURLToPath } from "node:url";
|
|
20
|
-
|
|
21
|
-
import { buildRoutingSnapshot } from "./routing-snapshot.mjs";
|
|
22
|
-
|
|
23
|
-
function stableStringify(obj) {
|
|
24
|
-
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
|
|
25
|
-
if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(",")}]`;
|
|
26
|
-
const keys = Object.keys(obj).sort();
|
|
27
|
-
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function hashInputs(request, snapshot) {
|
|
31
|
-
return createHash("sha256")
|
|
32
|
-
.update(stableStringify({ request, snapshot }))
|
|
33
|
-
.digest("hex")
|
|
34
|
-
.slice(0, 16);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function makeFallback(policy, trigger, inputsHash, now) {
|
|
38
|
-
const fb = policy?.fallback ?? {};
|
|
39
|
-
return {
|
|
40
|
-
decisionId: inputsHash,
|
|
41
|
-
mode: fb.default_mode ?? "codex-default",
|
|
42
|
-
scenario: "fallback",
|
|
43
|
-
lane: "single",
|
|
44
|
-
shards: [
|
|
45
|
-
{
|
|
46
|
-
cli: fb.default_cli ?? "codex",
|
|
47
|
-
accountId: null,
|
|
48
|
-
model: fb.default_model ?? "gpt-6.1-sol",
|
|
49
|
-
riskTier: "safe",
|
|
50
|
-
reason: trigger,
|
|
51
|
-
},
|
|
52
|
-
],
|
|
53
|
-
rationale: {
|
|
54
|
-
inputsHash,
|
|
55
|
-
biasScore: null,
|
|
56
|
-
alternatives: [],
|
|
57
|
-
},
|
|
58
|
-
fallback: { trigger, action: "codex-default" },
|
|
59
|
-
snapshotAgeMs: snapshotAge(undefined, now),
|
|
60
|
-
confidence: 50,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function snapshotAge(snapshot, now) {
|
|
65
|
-
if (!snapshot || typeof snapshot !== "object") return null;
|
|
66
|
-
const observed = Number(snapshot.observedAt);
|
|
67
|
-
if (!Number.isFinite(observed)) return null;
|
|
68
|
-
return Math.max(0, now - observed);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function checkFreshness(snapshot, freshness, now) {
|
|
72
|
-
if (!snapshot) return "hud_null";
|
|
73
|
-
const max = freshness?.max_snapshot_age_ms ?? 900_000;
|
|
74
|
-
const skewMax = freshness?.max_cross_provider_skew_ms ?? 300_000;
|
|
75
|
-
|
|
76
|
-
const claude = snapshot.claude;
|
|
77
|
-
const codex = snapshot.codex;
|
|
78
|
-
if (!claude && !codex) return "hud_null";
|
|
79
|
-
if (claude && (claude.isStale || claude.error))
|
|
80
|
-
return "claude_stale_or_error";
|
|
81
|
-
if (codex && (codex.isStale || codex.error)) return "codex_stale_or_error";
|
|
82
|
-
|
|
83
|
-
const claudeAge = claude?.observedAt ? now - claude.observedAt : null;
|
|
84
|
-
const codexAge = codex?.observedAt ? now - codex.observedAt : null;
|
|
85
|
-
if (claudeAge !== null && claudeAge > max) return "claude_age_exceeded";
|
|
86
|
-
if (codexAge !== null && codexAge > max) return "codex_age_exceeded";
|
|
87
|
-
if (claudeAge !== null && codexAge !== null) {
|
|
88
|
-
if (Math.abs(claudeAge - codexAge) > skewMax)
|
|
89
|
-
return "cross_provider_skew_exceeded";
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (claude?.weeklyResetsAt !== null && claude?.weeklyResetsAt !== undefined) {
|
|
93
|
-
if (
|
|
94
|
-
claude.weeklyResetsAt <
|
|
95
|
-
now - (freshness?.local_clock_skew_guard_ms ?? 60_000)
|
|
96
|
-
) {
|
|
97
|
-
return "claude_weekly_reset_in_past";
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function brokerAvailableCount(snapshot, provider) {
|
|
105
|
-
const accounts = snapshot?.broker?.[provider] ?? [];
|
|
106
|
-
return accounts.filter((a) => a.circuitState !== "open").length;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function brokerAllCircuitsOpen(snapshot) {
|
|
110
|
-
const groups = snapshot?.broker;
|
|
111
|
-
if (!groups) return false;
|
|
112
|
-
const all = [];
|
|
113
|
-
for (const provider of Object.keys(groups)) {
|
|
114
|
-
for (const acct of groups[provider] ?? []) all.push(acct);
|
|
115
|
-
}
|
|
116
|
-
if (all.length === 0) return true;
|
|
117
|
-
return all.every((a) => a.circuitState === "open");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ─── Scenario evaluators ──────────────────────────────────────────────
|
|
121
|
-
// Each returns either null (no match) or a partial RouteDecision payload
|
|
122
|
-
// `{ scenario, lane, shards, riskTier?, reasonCodes }` to be wrapped by
|
|
123
|
-
// `makeScenarioDecision()` below.
|
|
124
|
-
|
|
125
|
-
function evalS1(ctx) {
|
|
126
|
-
const { snapshot, scenario, policy, now } = ctx;
|
|
127
|
-
const claude = snapshot.claude;
|
|
128
|
-
if (!claude) return null;
|
|
129
|
-
const resetIn = (claude.weeklyResetsAt ?? Number.POSITIVE_INFINITY) - now;
|
|
130
|
-
if (
|
|
131
|
-
resetIn >
|
|
132
|
-
(scenario.when.weeklyResetsAtLteMs ?? policy.thresholds.reset_imminent_ms)
|
|
133
|
-
)
|
|
134
|
-
return null;
|
|
135
|
-
if ((claude.weeklyPercent ?? 100) >= (scenario.when.weeklyPercentLt ?? 100))
|
|
136
|
-
return null;
|
|
137
|
-
if (
|
|
138
|
-
scenario.when.hasMultipleAccounts &&
|
|
139
|
-
brokerAvailableCount(snapshot, "claude") < 2
|
|
140
|
-
)
|
|
141
|
-
return null;
|
|
142
|
-
return {
|
|
143
|
-
lane: scenario.then.lane ?? "multi",
|
|
144
|
-
shards: [
|
|
145
|
-
{ cli: "claude", riskTier: "stretch", reason: scenario.then.reason },
|
|
146
|
-
],
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function evalS2(ctx) {
|
|
151
|
-
const { snapshot, scenario, policy, now } = ctx;
|
|
152
|
-
const codex = snapshot.codex;
|
|
153
|
-
if (!codex) return null;
|
|
154
|
-
const buckets = codex.buckets ?? {};
|
|
155
|
-
const window =
|
|
156
|
-
scenario.when.weeklyResetsAtLteMs ?? policy.thresholds.reset_imminent_ms;
|
|
157
|
-
const usageCeiling = scenario.when.weeklyPercentLt ?? 100;
|
|
158
|
-
// Inspect both primary and secondary legs so a fresh secondary tier with
|
|
159
|
-
// imminent reset is not silently ignored when primary is exhausted.
|
|
160
|
-
const anyLegMatches = Object.values(buckets).some((b) =>
|
|
161
|
-
[b?.primary, b?.secondary].some((leg) => {
|
|
162
|
-
if (!leg) return false;
|
|
163
|
-
if (!leg.resetsAt) return false;
|
|
164
|
-
if (leg.resetsAt - now > window) return false;
|
|
165
|
-
return (leg.usedPercent ?? 100) < usageCeiling;
|
|
166
|
-
}),
|
|
167
|
-
);
|
|
168
|
-
if (!anyLegMatches) return null;
|
|
169
|
-
return {
|
|
170
|
-
lane: scenario.then.lane ?? "swarm",
|
|
171
|
-
shards: [
|
|
172
|
-
{ cli: "codex", riskTier: "stretch", reason: scenario.then.reason },
|
|
173
|
-
],
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function evalS3(ctx) {
|
|
178
|
-
const { snapshot, scenario, now } = ctx;
|
|
179
|
-
const claude = snapshot.claude;
|
|
180
|
-
const codex = snapshot.codex;
|
|
181
|
-
if (!claude || !codex) return null;
|
|
182
|
-
if ((claude.weeklyPercent ?? 0) < scenario.when.claude_weekly_percent_gte)
|
|
183
|
-
return null;
|
|
184
|
-
const codexAnyBucket = Object.values(codex.buckets ?? {}).some(
|
|
185
|
-
(b) =>
|
|
186
|
-
(b?.primary?.usedPercent ?? 100) < scenario.when.codex_weekly_percent_lt,
|
|
187
|
-
);
|
|
188
|
-
if (!codexAnyBucket) return null;
|
|
189
|
-
const resetIn = (claude.weeklyResetsAt ?? 0) - now;
|
|
190
|
-
if (resetIn <= scenario.when.claude_weekly_resets_at_gt_ms) return null;
|
|
191
|
-
return {
|
|
192
|
-
lane: scenario.then.lane ?? "single",
|
|
193
|
-
shards: [{ cli: "codex", riskTier: "safe", reason: scenario.then.reason }],
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function evalS4(ctx) {
|
|
198
|
-
const { snapshot, scenario } = ctx;
|
|
199
|
-
const claude = snapshot.broker?.claude ?? [];
|
|
200
|
-
if (claude.length < (scenario.when.accountCountGte ?? 2)) return null;
|
|
201
|
-
// Staggered = at least 2 distinct cooldown/expiresAt anchors.
|
|
202
|
-
const anchors = new Set(
|
|
203
|
-
claude.map((a) => `${a.cooldownUntil ?? 0}-${a.expiresAt ?? 0}`),
|
|
204
|
-
);
|
|
205
|
-
if (scenario.when.staggered && anchors.size < 2) return null;
|
|
206
|
-
return {
|
|
207
|
-
lane: scenario.then.lane ?? "multi",
|
|
208
|
-
shards: claude.map((a) => ({
|
|
209
|
-
cli: "claude",
|
|
210
|
-
accountId: a.id,
|
|
211
|
-
riskTier: "stretch",
|
|
212
|
-
reason: scenario.then.reason,
|
|
213
|
-
})),
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function evalS5(ctx) {
|
|
218
|
-
const { request, snapshot, scenario } = ctx;
|
|
219
|
-
if ((request?.team_size ?? 1) < (scenario.when.request_team_size_gte ?? 2))
|
|
220
|
-
return null;
|
|
221
|
-
const claude = snapshot.broker?.claude ?? [];
|
|
222
|
-
if (claude.length < (scenario.when.accountCountGte ?? 2)) return null;
|
|
223
|
-
const wanted = Math.min(request.team_size, claude.length);
|
|
224
|
-
return {
|
|
225
|
-
lane: scenario.then.lane ?? "swarm",
|
|
226
|
-
atomic: scenario.then.atomic,
|
|
227
|
-
atomicPolicy: scenario.then.atomic_policy ?? "balanced",
|
|
228
|
-
shards: claude.slice(0, wanted).map((a) => ({
|
|
229
|
-
cli: "claude",
|
|
230
|
-
accountId: a.id,
|
|
231
|
-
riskTier: "stretch",
|
|
232
|
-
reason: scenario.then.reason,
|
|
233
|
-
})),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function evalS6(ctx) {
|
|
238
|
-
const { snapshot, scenario, now } = ctx;
|
|
239
|
-
const all = [];
|
|
240
|
-
for (const p of Object.keys(snapshot.broker ?? {})) {
|
|
241
|
-
const accounts = snapshot.broker[p];
|
|
242
|
-
if (!Array.isArray(accounts)) continue;
|
|
243
|
-
for (const a of accounts) all.push({ ...a, provider: p });
|
|
244
|
-
}
|
|
245
|
-
const window = scenario.when.expiresAtLteMs ?? 604_800_000;
|
|
246
|
-
const usageCeiling = scenario.when.weeklyPercentLt ?? null;
|
|
247
|
-
const expiring = all.filter((a) => {
|
|
248
|
-
if (!a.expiresAt) return false;
|
|
249
|
-
// Past-dated expirations are dead accounts, not drain targets.
|
|
250
|
-
if (a.expiresAt - now <= 0) return false;
|
|
251
|
-
if (a.expiresAt - now > window) return false;
|
|
252
|
-
if (scenario.when.circuitStateNotOpen && a.circuitState === "open")
|
|
253
|
-
return false;
|
|
254
|
-
if (usageCeiling !== null) {
|
|
255
|
-
const usage = providerUsagePercent(snapshot, a.provider);
|
|
256
|
-
if (usage !== null && usage >= usageCeiling) return false;
|
|
257
|
-
}
|
|
258
|
-
return true;
|
|
259
|
-
});
|
|
260
|
-
if (expiring.length === 0) return null;
|
|
261
|
-
return {
|
|
262
|
-
lane: scenario.then.lane ?? "single",
|
|
263
|
-
riskTier: scenario.then.riskTier ?? "drain",
|
|
264
|
-
shards: expiring.map((a) => ({
|
|
265
|
-
cli: a.provider === "claude" ? "claude" : "codex",
|
|
266
|
-
accountId: a.id,
|
|
267
|
-
riskTier: "drain",
|
|
268
|
-
reason: scenario.then.reason,
|
|
269
|
-
})),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function providerUsagePercent(snapshot, provider) {
|
|
274
|
-
if (provider === "claude") {
|
|
275
|
-
return typeof snapshot.claude?.weeklyPercent === "number"
|
|
276
|
-
? snapshot.claude.weeklyPercent
|
|
277
|
-
: null;
|
|
278
|
-
}
|
|
279
|
-
if (provider === "codex") {
|
|
280
|
-
const buckets = Object.values(snapshot.codex?.buckets ?? {});
|
|
281
|
-
if (buckets.length === 0) return null;
|
|
282
|
-
let max = 0;
|
|
283
|
-
for (const b of buckets) {
|
|
284
|
-
for (const leg of [b?.primary, b?.secondary]) {
|
|
285
|
-
if (typeof leg?.usedPercent === "number" && leg.usedPercent > max) {
|
|
286
|
-
max = leg.usedPercent;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return max;
|
|
291
|
-
}
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const SCENARIO_EVALUATORS = {
|
|
296
|
-
S1: evalS1,
|
|
297
|
-
S2: evalS2,
|
|
298
|
-
S3: evalS3,
|
|
299
|
-
S4: evalS4,
|
|
300
|
-
S5: evalS5,
|
|
301
|
-
S6: evalS6,
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
function makeScenarioDecision({ match, scenario, inputsHash, snapshot, now }) {
|
|
305
|
-
return {
|
|
306
|
-
decisionId: inputsHash,
|
|
307
|
-
mode: "dynamic-overlay",
|
|
308
|
-
scenario: scenario.id,
|
|
309
|
-
lane: match.lane,
|
|
310
|
-
shards: match.shards,
|
|
311
|
-
atomic: match.atomic ?? null,
|
|
312
|
-
atomicPolicy: match.atomicPolicy ?? null,
|
|
313
|
-
rationale: {
|
|
314
|
-
inputsHash,
|
|
315
|
-
biasScore: null,
|
|
316
|
-
alternatives: [],
|
|
317
|
-
scenarioName: scenario.name,
|
|
318
|
-
},
|
|
319
|
-
fallback: { trigger: null, action: "codex-default" },
|
|
320
|
-
snapshotAgeMs: snapshotAge(snapshot, now),
|
|
321
|
-
confidence: 72,
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function brokerIsEmpty(snapshot) {
|
|
326
|
-
const groups = snapshot?.broker;
|
|
327
|
-
if (!groups || typeof groups !== "object") return true;
|
|
328
|
-
for (const p of Object.keys(groups)) {
|
|
329
|
-
const accounts = groups[p];
|
|
330
|
-
if (Array.isArray(accounts) && accounts.length > 0) return false;
|
|
331
|
-
}
|
|
332
|
-
return true;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* @param {object} request
|
|
337
|
-
* @param {object} snapshot
|
|
338
|
-
* @param {object} policy
|
|
339
|
-
* @param {object} options
|
|
340
|
-
* @param {number} options.now — REQUIRED. Pure-fn contract: callers must
|
|
341
|
-
* supply the current epoch; the engine itself never reads the clock.
|
|
342
|
-
*/
|
|
343
|
-
export function evaluateDynamicRoute(request, snapshot, policy, options) {
|
|
344
|
-
if (!options || typeof options.now !== "number") {
|
|
345
|
-
throw new TypeError(
|
|
346
|
-
"evaluateDynamicRoute: options.now (epoch ms) is required for pure-fn contract",
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
const { now } = options;
|
|
350
|
-
const inputsHash = hashInputs(request, snapshot);
|
|
351
|
-
|
|
352
|
-
if (!policy || !Array.isArray(policy.scenarios)) {
|
|
353
|
-
return makeFallback(
|
|
354
|
-
{ fallback: { default_mode: "codex-default" } },
|
|
355
|
-
"policy_missing",
|
|
356
|
-
inputsHash,
|
|
357
|
-
now,
|
|
358
|
-
);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const freshnessVeto = checkFreshness(snapshot, policy.freshness, now);
|
|
362
|
-
if (freshnessVeto)
|
|
363
|
-
return makeFallback(policy, freshnessVeto, inputsHash, now);
|
|
364
|
-
|
|
365
|
-
if (brokerIsEmpty(snapshot)) {
|
|
366
|
-
return makeFallback(policy, "broker_empty", inputsHash, now);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (brokerAllCircuitsOpen(snapshot)) {
|
|
370
|
-
return makeFallback(policy, "all_circuits_open", inputsHash, now);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
for (const scenario of policy.scenarios) {
|
|
374
|
-
const evaluator = SCENARIO_EVALUATORS[scenario.id];
|
|
375
|
-
if (!evaluator) continue;
|
|
376
|
-
const match = evaluator({
|
|
377
|
-
request: request ?? {},
|
|
378
|
-
snapshot,
|
|
379
|
-
scenario,
|
|
380
|
-
policy,
|
|
381
|
-
now,
|
|
382
|
-
});
|
|
383
|
-
if (match)
|
|
384
|
-
return makeScenarioDecision({
|
|
385
|
-
match,
|
|
386
|
-
scenario,
|
|
387
|
-
inputsHash,
|
|
388
|
-
snapshot,
|
|
389
|
-
now,
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
return makeFallback(policy, "no_scenario_matched", inputsHash, now);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// ─── Phase 1 — caller-friendly facade ─────────────────────────────────
|
|
397
|
-
//
|
|
398
|
-
// Phase 0 keeps `evaluateDynamicRoute()` pure. Callers (conductor,
|
|
399
|
-
// swarm-hypervisor, tfx-route.sh) would otherwise duplicate the same
|
|
400
|
-
// plumbing: snapshot build + policy load + clock read. The facade below
|
|
401
|
-
// folds that plumbing into a single factory with opt-in env gating.
|
|
402
|
-
//
|
|
403
|
-
// Contract:
|
|
404
|
-
// - `evaluateDynamicRoute()` pure-fn signature is preserved unchanged.
|
|
405
|
-
// - `createDynamicRouter()` returns a router whose `routeRequest()` is
|
|
406
|
-
// a `null-router` (always emits the fallback decision) unless the
|
|
407
|
-
// opt-in `TRIFLUX_DYNAMIC_ROUTING` env flag is set.
|
|
408
|
-
// - Callers can inject `policy`, `snapshot`, `snapshotProvider`, `nowFn`
|
|
409
|
-
// for deterministic testing without touching disk or HUD providers.
|
|
410
|
-
|
|
411
|
-
const DEFAULT_POLICY_PATH = join(
|
|
412
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
413
|
-
"..",
|
|
414
|
-
"config",
|
|
415
|
-
"routing-policy.json",
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
export function isDynamicRoutingEnabled(env = process.env) {
|
|
419
|
-
const flag = env.TRIFLUX_DYNAMIC_ROUTING;
|
|
420
|
-
return flag === "1" || flag === "true";
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function loadPolicy(policyPath = DEFAULT_POLICY_PATH) {
|
|
424
|
-
// Missing/invalid policy → null. evaluateDynamicRoute() will then fall
|
|
425
|
-
// back to codex-default, which is the documented fail-closed behaviour.
|
|
426
|
-
try {
|
|
427
|
-
const raw = readFileSync(policyPath, "utf8");
|
|
428
|
-
return JSON.parse(raw);
|
|
429
|
-
} catch {
|
|
430
|
-
return null;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Caller-friendly factory. Works with zero arguments — defaults to
|
|
436
|
-
* `process.env`, the production `routing-policy.json`, and a snapshot
|
|
437
|
-
* provider that calls `buildRoutingSnapshot()`. Opt-in env-gated:
|
|
438
|
-
* `TRIFLUX_DYNAMIC_ROUTING=1|true` enables scenario evaluation; anything
|
|
439
|
-
* else returns a null-router that always emits the fallback decision.
|
|
440
|
-
*
|
|
441
|
-
* @param {object} [opts]
|
|
442
|
-
* @param {string} [opts.policyPath] — override the policy file path.
|
|
443
|
-
* @param {object} [opts.policy] — inject a policy object directly (testing).
|
|
444
|
-
* @param {(args:{forceRefresh:boolean, now:number}) => Promise<object>} [opts.snapshotProvider]
|
|
445
|
-
* @param {NodeJS.ProcessEnv} [opts.env] — override env (testing).
|
|
446
|
-
* @param {() => number} [opts.nowFn] — override clock (testing).
|
|
447
|
-
*/
|
|
448
|
-
export function createDynamicRouter(opts = {}) {
|
|
449
|
-
const env = opts.env || process.env;
|
|
450
|
-
const enabled = isDynamicRoutingEnabled(env);
|
|
451
|
-
const policy =
|
|
452
|
-
opts.policy !== undefined
|
|
453
|
-
? opts.policy
|
|
454
|
-
: enabled
|
|
455
|
-
? loadPolicy(opts.policyPath)
|
|
456
|
-
: null;
|
|
457
|
-
const snapshotProvider =
|
|
458
|
-
opts.snapshotProvider ||
|
|
459
|
-
(async ({ forceRefresh, now }) =>
|
|
460
|
-
buildRoutingSnapshot({ forceRefresh, now }));
|
|
461
|
-
const nowFn = opts.nowFn || (() => Date.now());
|
|
462
|
-
|
|
463
|
-
return {
|
|
464
|
-
enabled,
|
|
465
|
-
/**
|
|
466
|
-
* @param {object} request
|
|
467
|
-
* @param {{ snapshot?: object, now?: number, forceRefresh?: boolean }} [callOpts]
|
|
468
|
-
* @returns {Promise<object>} RouteDecision
|
|
469
|
-
*/
|
|
470
|
-
async routeRequest(request, callOpts = {}) {
|
|
471
|
-
const now = typeof callOpts.now === "number" ? callOpts.now : nowFn();
|
|
472
|
-
if (!enabled) {
|
|
473
|
-
// null-router: opt-in env absent → always fallback decision.
|
|
474
|
-
return evaluateDynamicRoute(request, {}, null, { now });
|
|
475
|
-
}
|
|
476
|
-
const snapshot =
|
|
477
|
-
callOpts.snapshot !== undefined
|
|
478
|
-
? callOpts.snapshot
|
|
479
|
-
: await snapshotProvider({
|
|
480
|
-
forceRefresh: Boolean(callOpts.forceRefresh),
|
|
481
|
-
now,
|
|
482
|
-
});
|
|
483
|
-
return evaluateDynamicRoute(request, snapshot, policy, { now });
|
|
484
|
-
},
|
|
485
|
-
getPolicy() {
|
|
486
|
-
return policy;
|
|
487
|
-
},
|
|
488
|
-
};
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Top-level convenience. Equivalent to
|
|
493
|
-
* `createDynamicRouter(opts).routeRequest(request, opts)`.
|
|
494
|
-
*/
|
|
495
|
-
export async function routeRequest(request, opts = {}) {
|
|
496
|
-
const router = createDynamicRouter(opts);
|
|
497
|
-
return router.routeRequest(request, opts);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
export const __internal__ = {
|
|
501
|
-
hashInputs,
|
|
502
|
-
checkFreshness,
|
|
503
|
-
brokerAvailableCount,
|
|
504
|
-
brokerAllCircuitsOpen,
|
|
505
|
-
brokerIsEmpty,
|
|
506
|
-
providerUsagePercent,
|
|
507
|
-
SCENARIO_EVALUATORS,
|
|
508
|
-
loadPolicy,
|
|
509
|
-
isDynamicRoutingEnabled,
|
|
510
|
-
DEFAULT_POLICY_PATH,
|
|
511
|
-
};
|
package/hub/hitl.mjs
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// hub/hitl.mjs — Human-in-the-Loop 매니저
|
|
2
|
-
// 사용자 입력 요청/응답, 타임아웃 자동 처리
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* HITL 매니저 생성
|
|
6
|
-
* @param {object} store — createStore() 반환 객체
|
|
7
|
-
* @param {object} router — createRouter() 반환 객체
|
|
8
|
-
*/
|
|
9
|
-
export function createHitlManager(store, router = null) {
|
|
10
|
-
function forwardHumanResponse({
|
|
11
|
-
requesterAgent,
|
|
12
|
-
requestId,
|
|
13
|
-
action,
|
|
14
|
-
content,
|
|
15
|
-
submittedBy,
|
|
16
|
-
correlationId,
|
|
17
|
-
traceId,
|
|
18
|
-
priority,
|
|
19
|
-
}) {
|
|
20
|
-
if (!router?.handlePublish) {
|
|
21
|
-
throw new Error("router.handlePublish is required for HITL forwarding");
|
|
22
|
-
}
|
|
23
|
-
return router.handlePublish({
|
|
24
|
-
from: "hub:hitl",
|
|
25
|
-
to: requesterAgent,
|
|
26
|
-
topic: "human.response",
|
|
27
|
-
priority,
|
|
28
|
-
ttl_ms: 300000,
|
|
29
|
-
payload: {
|
|
30
|
-
request_id: requestId,
|
|
31
|
-
action,
|
|
32
|
-
content,
|
|
33
|
-
submitted_by: submittedBy,
|
|
34
|
-
},
|
|
35
|
-
correlation_id: correlationId,
|
|
36
|
-
trace_id: traceId,
|
|
37
|
-
message_type: "human_response",
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
/**
|
|
43
|
-
* 사용자에게 입력 요청 생성
|
|
44
|
-
* 터미널에 알림 출력 후 pending 상태로 저장
|
|
45
|
-
*/
|
|
46
|
-
requestHumanInput({
|
|
47
|
-
requester_agent,
|
|
48
|
-
kind,
|
|
49
|
-
prompt,
|
|
50
|
-
requested_schema = {},
|
|
51
|
-
deadline_ms,
|
|
52
|
-
default_action,
|
|
53
|
-
channel_preference = "terminal",
|
|
54
|
-
correlation_id,
|
|
55
|
-
trace_id,
|
|
56
|
-
}) {
|
|
57
|
-
const result = store.insertHumanRequest({
|
|
58
|
-
requester_agent,
|
|
59
|
-
kind,
|
|
60
|
-
prompt,
|
|
61
|
-
requested_schema,
|
|
62
|
-
deadline_ms,
|
|
63
|
-
default_action,
|
|
64
|
-
correlation_id,
|
|
65
|
-
trace_id,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
// 터미널 알림 (stderr — stdout은 MCP 용)
|
|
69
|
-
const kindLabel = {
|
|
70
|
-
captcha: "CAPTCHA",
|
|
71
|
-
approval: "승인",
|
|
72
|
-
credential: "자격증명",
|
|
73
|
-
choice: "선택",
|
|
74
|
-
text: "텍스트",
|
|
75
|
-
};
|
|
76
|
-
process.stderr.write(
|
|
77
|
-
`\n[tfx-hub] 사용자 입력 요청 (${kindLabel[kind] || kind})\n` +
|
|
78
|
-
` 요청자: ${requester_agent}\n` +
|
|
79
|
-
` 내용: ${prompt}\n` +
|
|
80
|
-
` ID: ${result.request_id}\n` +
|
|
81
|
-
` 제한: ${Math.round(deadline_ms / 1000)}초\n\n`,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
return { ok: true, data: result };
|
|
85
|
-
},
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* 사용자 입력 응답 제출
|
|
89
|
-
* 유효성 검증 → 상태 업데이트 → 요청자에게 응답 메시지 전달
|
|
90
|
-
*/
|
|
91
|
-
submitHumanInput({
|
|
92
|
-
request_id,
|
|
93
|
-
action,
|
|
94
|
-
content = null,
|
|
95
|
-
submitted_by = "human",
|
|
96
|
-
}) {
|
|
97
|
-
// 요청 조회
|
|
98
|
-
const hr = store.getHumanRequest(request_id);
|
|
99
|
-
if (!hr) {
|
|
100
|
-
return {
|
|
101
|
-
ok: false,
|
|
102
|
-
error: { code: "NOT_FOUND", message: `요청 없음: ${request_id}` },
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
if (hr.state !== "pending") {
|
|
106
|
-
return {
|
|
107
|
-
ok: false,
|
|
108
|
-
error: {
|
|
109
|
-
code: "ALREADY_HANDLED",
|
|
110
|
-
message: `이미 처리됨: ${hr.state}`,
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 상태 매핑
|
|
116
|
-
const stateMap = {
|
|
117
|
-
accept: "accepted",
|
|
118
|
-
decline: "declined",
|
|
119
|
-
cancel: "cancelled",
|
|
120
|
-
};
|
|
121
|
-
const newState = stateMap[action];
|
|
122
|
-
if (!newState) {
|
|
123
|
-
return {
|
|
124
|
-
ok: false,
|
|
125
|
-
error: {
|
|
126
|
-
code: "INVALID_ACTION",
|
|
127
|
-
message: `잘못된 action: ${action}`,
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// DB 업데이트
|
|
133
|
-
store.updateHumanRequest(request_id, newState, content);
|
|
134
|
-
|
|
135
|
-
// 요청자에게 응답 메시지 전달
|
|
136
|
-
let forwardedMessageId = null;
|
|
137
|
-
if (action === "accept" || action === "decline") {
|
|
138
|
-
const published = forwardHumanResponse({
|
|
139
|
-
requesterAgent: hr.requester_agent,
|
|
140
|
-
requestId: request_id,
|
|
141
|
-
action,
|
|
142
|
-
content,
|
|
143
|
-
submittedBy: submitted_by,
|
|
144
|
-
correlationId: hr.correlation_id,
|
|
145
|
-
traceId: hr.trace_id,
|
|
146
|
-
priority: 7,
|
|
147
|
-
});
|
|
148
|
-
forwardedMessageId = published.data?.message_id || null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
ok: true,
|
|
153
|
-
data: {
|
|
154
|
-
request_id,
|
|
155
|
-
new_state: newState,
|
|
156
|
-
forwarded_message_id: forwardedMessageId,
|
|
157
|
-
},
|
|
158
|
-
};
|
|
159
|
-
},
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* 만료된 요청 자동 처리
|
|
163
|
-
* deadline 초과 시 default_action 적용
|
|
164
|
-
*/
|
|
165
|
-
checkTimeouts() {
|
|
166
|
-
const pending = store.getPendingHumanRequests();
|
|
167
|
-
const now = Date.now();
|
|
168
|
-
const expired = pending.filter((hr) => hr.deadline_ms <= now);
|
|
169
|
-
if (!expired.length) return 0;
|
|
170
|
-
|
|
171
|
-
const expireRequests = () => {
|
|
172
|
-
for (const hr of expired) {
|
|
173
|
-
store.updateHumanRequest(hr.request_id, "timed_out", null);
|
|
174
|
-
if (hr.default_action === "timeout_continue") {
|
|
175
|
-
forwardHumanResponse({
|
|
176
|
-
requesterAgent: hr.requester_agent,
|
|
177
|
-
requestId: hr.request_id,
|
|
178
|
-
action: "timeout_continue",
|
|
179
|
-
content: null,
|
|
180
|
-
submittedBy: "system",
|
|
181
|
-
correlationId: hr.correlation_id,
|
|
182
|
-
traceId: hr.trace_id,
|
|
183
|
-
priority: 5,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return expired.length;
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const processExpired = store.db?.transaction
|
|
191
|
-
? store.db.transaction(expireRequests)
|
|
192
|
-
: expireRequests;
|
|
193
|
-
|
|
194
|
-
return processExpired();
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
/** 대기 중인 요청 목록 */
|
|
198
|
-
getPendingRequests() {
|
|
199
|
-
return store.getPendingHumanRequests();
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
}
|