triflux 10.42.0 → 10.43.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/bin/tfx-live.mjs +3 -1
- package/config/routing-policy.json +1 -1
- package/hooks/pipeline-stop.mjs +1 -1
- package/hub/bridge.mjs +80 -1
- package/hub/cli-adapter-base.mjs +100 -19
- package/hub/codex-adapter.mjs +5 -1
- package/hub/dynamic-routing-engine.mjs +1 -1
- package/hub/gemini-adapter.mjs +3 -1
- package/hub/intent.mjs +13 -9
- package/hub/lib/cto-env.mjs +46 -0
- package/hub/lib/memory-store.mjs +25 -13
- package/hub/lib/timeout-defaults.mjs +45 -0
- package/hub/lib/worker-lifecycle.mjs +101 -0
- package/hub/middleware/request-logger.mjs +4 -1
- package/hub/pipe.mjs +9 -0
- package/hub/role-contract.mjs +314 -0
- package/hub/role-control-events.mjs +113 -0
- package/hub/router.mjs +111 -29
- package/hub/schema.sql +1 -0
- package/hub/server.mjs +88 -43
- package/hub/store.mjs +35 -17
- package/hub/team/claude-daemon-control.mjs +28 -15
- package/hub/team/cli/commands/start/parse-args.mjs +2 -2
- package/hub/team/headless.mjs +295 -24
- package/hub/team/intervention.mjs +530 -0
- package/hub/team/retry-state-machine.mjs +6 -2
- package/hub/team/swarm-hypervisor.mjs +20 -2
- package/hub/team/swarm-locks.mjs +17 -1
- package/hub/team/uds-orchestrator.mjs +62 -9
- package/hub/tools.mjs +8 -3
- package/hub/workers/codex-app-server-worker.mjs +54 -5
- package/hub/workers/codex-mcp.mjs +1 -1
- package/hub/workers/delegator-mcp.mjs +18 -18
- package/hud/constants.mjs +21 -0
- package/hud/context-monitor.mjs +18 -21
- package/hud/hud-qos-status.mjs +1 -1
- package/hud/providers/codex-probe.mjs +216 -0
- package/hud/providers/codex.mjs +206 -29
- package/hud/renderers.mjs +6 -9
- package/package.json +1 -1
- package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +1 -1
- package/scripts/__tests__/tfx-route-node-entry.test.mjs +6 -6
- package/scripts/headless-guard.mjs +70 -23
- package/scripts/lib/cli-agy.mjs +1 -0
- package/scripts/lib/cli-claude.mjs +1 -0
- package/scripts/lib/cli-codex.mjs +22 -21
- package/scripts/lib/codex-profile-config.mjs +2 -2
- package/scripts/setup.mjs +15 -11
- package/scripts/tfx-gate-activate.mjs +1 -1
- package/scripts/tfx-route-worker.mjs +5 -0
- package/scripts/tfx-route.sh +136 -84
- package/skills/tfx-analysis/SKILL.md +3 -1
- package/skills/tfx-hub/SKILL.md +1 -1
- package/skills/tfx-plan/SKILL.md +3 -1
- package/skills/tfx-profile/SKILL.md +9 -9
- package/skills/tfx-prune/SKILL.md +4 -2
- package/skills/tfx-qa/SKILL.md +6 -2
- package/skills/tfx-research/SKILL.md +3 -1
- package/skills/tfx-review/SKILL.md +5 -3
- package/skills/tfx-setup/SKILL.md +1 -1
- package/tui/codex-profile.mjs +3 -1
- package/tui/setup.mjs +4 -4
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// 활동 기반 워커 lifecycle. 시간 기본값은 timeout-defaults SSOT에서만 가져온다.
|
|
2
|
+
import {
|
|
3
|
+
resolveHardCeilingMs,
|
|
4
|
+
resolveStallInterventionMs,
|
|
5
|
+
} from "./timeout-defaults.mjs";
|
|
6
|
+
|
|
7
|
+
export { resolveHardCeilingMs, resolveStallInterventionMs };
|
|
8
|
+
|
|
9
|
+
export function isActivityLifecycleEnabled(env = process.env) {
|
|
10
|
+
return env.TFX_ACTIVITY_LIFECYCLE !== "0";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 활동 신호를 기준으로 무활동 개입과 절대 상한을 판정한다.
|
|
15
|
+
* 호출자는 observe()에 자기 채널의 기존 활동 서명을 전달하고, check()에
|
|
16
|
+
* 개입 대상 문맥을 제공한다. 이 모듈은 신호를 생성하거나 개입을 실행하지 않는다.
|
|
17
|
+
*/
|
|
18
|
+
export function createActivityLifecycle({
|
|
19
|
+
enabled = isActivityLifecycleEnabled(),
|
|
20
|
+
interventionMs = resolveStallInterventionMs(),
|
|
21
|
+
hardCeilingMs = resolveHardCeilingMs(),
|
|
22
|
+
maxInterventions = 1,
|
|
23
|
+
onIntervene,
|
|
24
|
+
now = Date.now,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const startedAt = now();
|
|
27
|
+
let lastActivityAt = startedAt;
|
|
28
|
+
let lastSignature;
|
|
29
|
+
let hasSignature = false;
|
|
30
|
+
let interventions = 0;
|
|
31
|
+
let timeoutReason = "";
|
|
32
|
+
let pendingIntervention = null;
|
|
33
|
+
|
|
34
|
+
const markActivity = () => {
|
|
35
|
+
lastActivityAt = now();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function intervene(context = {}) {
|
|
39
|
+
if (!enabled || typeof onIntervene !== "function") return false;
|
|
40
|
+
if (interventions >= maxInterventions || pendingIntervention) return false;
|
|
41
|
+
|
|
42
|
+
interventions += 1;
|
|
43
|
+
const inactiveMs = Math.max(0, now() - lastActivityAt);
|
|
44
|
+
pendingIntervention = Promise.resolve(
|
|
45
|
+
onIntervene({ ...context, inactiveMs, interventions }),
|
|
46
|
+
)
|
|
47
|
+
.then((handled) => handled === true)
|
|
48
|
+
.catch(() => false);
|
|
49
|
+
try {
|
|
50
|
+
const handled = await pendingIntervention;
|
|
51
|
+
if (handled) markActivity();
|
|
52
|
+
return handled;
|
|
53
|
+
} finally {
|
|
54
|
+
pendingIntervention = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
observe(...args) {
|
|
60
|
+
if (!enabled) return false;
|
|
61
|
+
const [signature] = args;
|
|
62
|
+
const changed =
|
|
63
|
+
args.length === 0 || !hasSignature || signature !== lastSignature;
|
|
64
|
+
if (changed) {
|
|
65
|
+
lastSignature = signature;
|
|
66
|
+
hasSignature = true;
|
|
67
|
+
markActivity();
|
|
68
|
+
}
|
|
69
|
+
return changed;
|
|
70
|
+
},
|
|
71
|
+
async check(context = {}) {
|
|
72
|
+
if (!enabled || timeoutReason) return timeoutReason;
|
|
73
|
+
const current = now();
|
|
74
|
+
if (current - startedAt >= hardCeilingMs) {
|
|
75
|
+
timeoutReason = "hard_ceiling";
|
|
76
|
+
return timeoutReason;
|
|
77
|
+
}
|
|
78
|
+
if (current - lastActivityAt < interventionMs || pendingIntervention)
|
|
79
|
+
return "";
|
|
80
|
+
if (await intervene(context)) return "";
|
|
81
|
+
timeoutReason = "inactivity";
|
|
82
|
+
return timeoutReason;
|
|
83
|
+
},
|
|
84
|
+
intervene,
|
|
85
|
+
get enabled() {
|
|
86
|
+
return enabled;
|
|
87
|
+
},
|
|
88
|
+
get hardCeilingMs() {
|
|
89
|
+
return hardCeilingMs;
|
|
90
|
+
},
|
|
91
|
+
get interventionMs() {
|
|
92
|
+
return interventionMs;
|
|
93
|
+
},
|
|
94
|
+
get interventions() {
|
|
95
|
+
return interventions;
|
|
96
|
+
},
|
|
97
|
+
get timeoutReason() {
|
|
98
|
+
return timeoutReason;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* health/status 체크는 로깅을 건너뛴다.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { HUB_REQUEST_MONITOR_CACHE_PATH } from "../../hud/constants.mjs";
|
|
14
15
|
import { createContextMonitor } from "../../hud/context-monitor.mjs";
|
|
15
16
|
import {
|
|
16
17
|
getCorrelationId,
|
|
@@ -19,7 +20,9 @@ import {
|
|
|
19
20
|
import { createModuleLogger } from "../../scripts/lib/logger.mjs";
|
|
20
21
|
|
|
21
22
|
const log = createModuleLogger("hub");
|
|
22
|
-
const contextMonitor = createContextMonitor(
|
|
23
|
+
const contextMonitor = createContextMonitor({
|
|
24
|
+
cachePath: HUB_REQUEST_MONITOR_CACHE_PATH,
|
|
25
|
+
});
|
|
23
26
|
|
|
24
27
|
const SKIP_PATHS = new Set(["/health", "/healthz", "/status", "/ready"]);
|
|
25
28
|
const MAX_CAPTURE_BYTES = 256 * 1024;
|
package/hub/pipe.mjs
CHANGED
|
@@ -269,6 +269,15 @@ export function createPipeServer({
|
|
|
269
269
|
payload.heartbeat_ttl_ms || heartbeatTtlMs,
|
|
270
270
|
);
|
|
271
271
|
if (client) touchClient(client);
|
|
272
|
+
if (result?.effective?.updated === false) {
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
error: {
|
|
276
|
+
code: "AGENT_NOT_FOUND",
|
|
277
|
+
message: `heartbeat target not registered: ${agentId}`,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
272
281
|
return { ok: true, data: result };
|
|
273
282
|
}
|
|
274
283
|
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
const PROJECT_ID_RE = /^prj_[A-Za-z0-9_-]{22}$/u;
|
|
2
|
+
const SCOPE_ID_RE = /^scp_[A-Za-z0-9_-]{22}$/u;
|
|
3
|
+
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/u;
|
|
4
|
+
|
|
5
|
+
export const ROLE_KINDS = Object.freeze(["cto", "lead"]);
|
|
6
|
+
export const ROLE_REACHABILITY_STATES = Object.freeze([
|
|
7
|
+
"electable",
|
|
8
|
+
"elected-probing",
|
|
9
|
+
"active",
|
|
10
|
+
"unreachable",
|
|
11
|
+
"quarantined",
|
|
12
|
+
]);
|
|
13
|
+
export const ROLE_STATUS_SCHEMA_VERSION = "tfx.roles.v2";
|
|
14
|
+
export const LEGACY_ROLE_SNAPSHOT_FIELDS = Object.freeze([
|
|
15
|
+
"role",
|
|
16
|
+
"status",
|
|
17
|
+
"leader_agent_id",
|
|
18
|
+
"previous_leader_agent_id",
|
|
19
|
+
"leader_epoch",
|
|
20
|
+
"lease_expires_ms",
|
|
21
|
+
"candidate_source",
|
|
22
|
+
"candidate_count",
|
|
23
|
+
"live_candidate_count",
|
|
24
|
+
"pending_count",
|
|
25
|
+
"transferred_count",
|
|
26
|
+
"last_transition_ms",
|
|
27
|
+
"last_reason",
|
|
28
|
+
"candidates",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function roleContractError(code, message = code) {
|
|
32
|
+
const error = new Error(message);
|
|
33
|
+
error.code = code;
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function canonicalJsonRoleKey(key) {
|
|
42
|
+
return key.role_kind === "lead"
|
|
43
|
+
? `{"project_id":${JSON.stringify(key.project_id)},"role_kind":"lead","scope_id":${JSON.stringify(key.scope_id)}}`
|
|
44
|
+
: `{"project_id":${JSON.stringify(key.project_id)},"role_kind":"cto"}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate and normalize the only two valid dynamic role identities.
|
|
49
|
+
*
|
|
50
|
+
* @param {unknown} input
|
|
51
|
+
* @returns {{project_id: string, role_kind: "cto"} | {project_id: string, role_kind: "lead", scope_id: string}}
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeRoleKey(input) {
|
|
54
|
+
if (!isRecord(input)) throw roleContractError("ROLE_KEY_INVALID");
|
|
55
|
+
|
|
56
|
+
const fields = Object.keys(input);
|
|
57
|
+
const allowed = new Set(["project_id", "role_kind", "scope_id"]);
|
|
58
|
+
if (fields.some((field) => !allowed.has(field))) {
|
|
59
|
+
throw roleContractError("ROLE_KEY_FIELD_UNSUPPORTED");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { project_id: projectId, role_kind: roleKind } = input;
|
|
63
|
+
if (typeof projectId !== "string" || !PROJECT_ID_RE.test(projectId)) {
|
|
64
|
+
throw roleContractError("PROJECT_ID_INVALID");
|
|
65
|
+
}
|
|
66
|
+
if (!ROLE_KINDS.includes(roleKind)) {
|
|
67
|
+
throw roleContractError("ROLE_KIND_UNSUPPORTED");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const hasScope = Object.hasOwn(input, "scope_id");
|
|
71
|
+
if (roleKind === "cto") {
|
|
72
|
+
if (hasScope) throw roleContractError("ROLE_SCOPE_FORBIDDEN");
|
|
73
|
+
return { project_id: projectId, role_kind: "cto" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!hasScope || typeof input.scope_id !== "string") {
|
|
77
|
+
throw roleContractError("SCOPE_REQUIRED");
|
|
78
|
+
}
|
|
79
|
+
if (!SCOPE_ID_RE.test(input.scope_id)) {
|
|
80
|
+
throw roleContractError("SCOPE_ID_INVALID");
|
|
81
|
+
}
|
|
82
|
+
return { project_id: projectId, role_kind: "lead", scope_id: input.scope_id };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {unknown} key */
|
|
86
|
+
export function encodeRoleKey(key) {
|
|
87
|
+
const normalized = normalizeRoleKey(key);
|
|
88
|
+
const wire = `rk2.${Buffer.from(canonicalJsonRoleKey(normalized), "utf8").toString("base64url")}`;
|
|
89
|
+
if (Buffer.byteLength(wire, "utf8") > 512) {
|
|
90
|
+
throw roleContractError("ROLE_KEY_WIRE_TOO_LONG");
|
|
91
|
+
}
|
|
92
|
+
return wire;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {unknown} wire */
|
|
96
|
+
export function decodeRoleKey(wire) {
|
|
97
|
+
if (typeof wire !== "string" || Buffer.byteLength(wire, "utf8") > 512) {
|
|
98
|
+
throw roleContractError("ROLE_KEY_WIRE_INVALID");
|
|
99
|
+
}
|
|
100
|
+
if (!wire.startsWith("rk2.")) throw roleContractError("ROLE_KEY_WIRE_PREFIX");
|
|
101
|
+
|
|
102
|
+
const encoded = wire.slice(4);
|
|
103
|
+
if (!encoded || !BASE64URL_RE.test(encoded)) {
|
|
104
|
+
throw roleContractError("ROLE_KEY_NON_CANONICAL");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
const canonicalSource = Buffer.from(encoded, "base64url").toString("utf8");
|
|
110
|
+
parsed = JSON.parse(canonicalSource);
|
|
111
|
+
} catch {
|
|
112
|
+
throw roleContractError("ROLE_KEY_NON_CANONICAL");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let normalized;
|
|
116
|
+
try {
|
|
117
|
+
normalized = normalizeRoleKey(parsed);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error?.code) throw error;
|
|
120
|
+
throw roleContractError("ROLE_KEY_NON_CANONICAL");
|
|
121
|
+
}
|
|
122
|
+
if (encodeRoleKey(normalized) !== wire) {
|
|
123
|
+
throw roleContractError("ROLE_KEY_NON_CANONICAL");
|
|
124
|
+
}
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {unknown} key */
|
|
129
|
+
export function roleTopicAddress(key) {
|
|
130
|
+
return `topic:role:${encodeRoleKey(key)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const TRANSITION_ROWS = [
|
|
134
|
+
["electable", "candidate_selected", "elected-probing", "reserve_holder"],
|
|
135
|
+
["electable", "standup_reserved", "elected-probing", "reserve_standup"],
|
|
136
|
+
["electable", "no_candidate", "electable", "record_blocked_no_candidate"],
|
|
137
|
+
[
|
|
138
|
+
"electable",
|
|
139
|
+
"candidates_excluded",
|
|
140
|
+
"quarantined",
|
|
141
|
+
"schedule_exclusion_expiry",
|
|
142
|
+
],
|
|
143
|
+
["elected-probing", "probe_succeeded", "elected-probing", "send_activation"],
|
|
144
|
+
["elected-probing", "activation_acked", "active", "record_activation_ack"],
|
|
145
|
+
[
|
|
146
|
+
"elected-probing",
|
|
147
|
+
"probe_failed",
|
|
148
|
+
"unreachable",
|
|
149
|
+
"exclude_failed_candidate",
|
|
150
|
+
],
|
|
151
|
+
["elected-probing", "wake_failed", "unreachable", "exclude_failed_candidate"],
|
|
152
|
+
["elected-probing", "ack_timeout", "unreachable", "exclude_failed_candidate"],
|
|
153
|
+
[
|
|
154
|
+
"elected-probing",
|
|
155
|
+
"holder_lease_expired",
|
|
156
|
+
"electable",
|
|
157
|
+
"revoke_holder_epoch_fence",
|
|
158
|
+
],
|
|
159
|
+
[
|
|
160
|
+
"active",
|
|
161
|
+
"holder_lease_expired",
|
|
162
|
+
"electable",
|
|
163
|
+
"revoke_holder_preserve_backlog",
|
|
164
|
+
],
|
|
165
|
+
["active", "holder_offline", "electable", "revoke_holder_preserve_backlog"],
|
|
166
|
+
["active", "transport_failed", "unreachable", "fence_active_authority"],
|
|
167
|
+
["active", "charter_reissued", "elected-probing", "reserve_reack"],
|
|
168
|
+
[
|
|
169
|
+
"unreachable",
|
|
170
|
+
"alternate_candidate_available",
|
|
171
|
+
"electable",
|
|
172
|
+
"failover_candidate",
|
|
173
|
+
],
|
|
174
|
+
["unreachable", "retry_due", "elected-probing", "reserve_retry_epoch"],
|
|
175
|
+
["unreachable", "retries_exhausted", "quarantined", "schedule_quarantine"],
|
|
176
|
+
[
|
|
177
|
+
"quarantined",
|
|
178
|
+
"exclusion_expired",
|
|
179
|
+
"electable",
|
|
180
|
+
"restore_candidate_eligibility",
|
|
181
|
+
],
|
|
182
|
+
["quarantined", "candidate_registered", "electable", "clear_quarantine"],
|
|
183
|
+
["quarantined", "locator_registered", "electable", "clear_quarantine"],
|
|
184
|
+
["quarantined", "capability_registered", "electable", "clear_quarantine"],
|
|
185
|
+
...ROLE_REACHABILITY_STATES.flatMap((state) => [
|
|
186
|
+
[state, "stale_ack", state, "reject_stale_ack"],
|
|
187
|
+
[state, "scope_closed", null, "remove_registry"],
|
|
188
|
+
]),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
export const ROLE_REACHABILITY_TRANSITIONS = Object.freeze(
|
|
192
|
+
TRANSITION_ROWS.map(([from, trigger, to, action]) =>
|
|
193
|
+
Object.freeze({ from, trigger, to, action }),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const TRANSITIONS_BY_KEY = new Map(
|
|
198
|
+
ROLE_REACHABILITY_TRANSITIONS.map((transition) => [
|
|
199
|
+
`${transition.from}:${transition.trigger}`,
|
|
200
|
+
transition,
|
|
201
|
+
]),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Apply one already-guarded reachability event. Guard evaluation and all side
|
|
206
|
+
* effects deliberately remain outside this inert contract kernel.
|
|
207
|
+
*/
|
|
208
|
+
export function reduceRoleReachabilityTransition(state, trigger) {
|
|
209
|
+
if (!ROLE_REACHABILITY_STATES.includes(state)) {
|
|
210
|
+
throw roleContractError("ROLE_REACHABILITY_STATE_INVALID");
|
|
211
|
+
}
|
|
212
|
+
const transition = TRANSITIONS_BY_KEY.get(`${state}:${trigger}`);
|
|
213
|
+
if (!transition) throw roleContractError("INVALID_ROLE_TRANSITION");
|
|
214
|
+
return trigger === "stale_ack"
|
|
215
|
+
? { state: transition.to, action: transition.action, code: "STALE_EPOCH" }
|
|
216
|
+
: { state: transition.to, action: transition.action };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const reduceRoleReachability = reduceRoleReachabilityTransition;
|
|
220
|
+
|
|
221
|
+
function snapshotLegacyDetails(snapshot) {
|
|
222
|
+
const legacy = isRecord(snapshot.legacy) ? snapshot.legacy : {};
|
|
223
|
+
return {
|
|
224
|
+
candidate_source:
|
|
225
|
+
legacy.candidate_source ?? snapshot.candidate_source ?? null,
|
|
226
|
+
transferred_count:
|
|
227
|
+
legacy.transferred_count ?? snapshot.transferred_count ?? 0,
|
|
228
|
+
candidates: legacy.candidates ?? snapshot.candidates ?? [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Construct the v2 status envelope in deterministic role-key order.
|
|
234
|
+
* `generatedAtMs` is supplied by the caller so the kernel has no clock.
|
|
235
|
+
*/
|
|
236
|
+
export function createRoleStatusEnvelopeV2({
|
|
237
|
+
items = [],
|
|
238
|
+
generatedAtMs,
|
|
239
|
+
projectId,
|
|
240
|
+
} = {}) {
|
|
241
|
+
if (!Number.isFinite(generatedAtMs)) {
|
|
242
|
+
throw roleContractError("ROLE_STATUS_GENERATED_AT_REQUIRED");
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
projectId !== undefined &&
|
|
246
|
+
(typeof projectId !== "string" || !PROJECT_ID_RE.test(projectId))
|
|
247
|
+
) {
|
|
248
|
+
throw roleContractError("PROJECT_ID_INVALID");
|
|
249
|
+
}
|
|
250
|
+
if (!Array.isArray(items))
|
|
251
|
+
throw roleContractError("ROLE_STATUS_ITEMS_INVALID");
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
schema_version: ROLE_STATUS_SCHEMA_VERSION,
|
|
255
|
+
generated_at_ms: generatedAtMs,
|
|
256
|
+
...(projectId === undefined ? {} : { project_id: projectId }),
|
|
257
|
+
items: [...items].sort((left, right) =>
|
|
258
|
+
String(left?.role_key_wire || "").localeCompare(
|
|
259
|
+
String(right?.role_key_wire || ""),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Project the single exact-project CTO into the legacy `roles.cto` shape.
|
|
267
|
+
* The optional `legacy` detail preserves fields that v2 callers do not use.
|
|
268
|
+
*/
|
|
269
|
+
export function projectLegacyCtoStatus(envelope, projectId) {
|
|
270
|
+
if (
|
|
271
|
+
!isRecord(envelope) ||
|
|
272
|
+
envelope.schema_version !== ROLE_STATUS_SCHEMA_VERSION ||
|
|
273
|
+
!Array.isArray(envelope.items)
|
|
274
|
+
) {
|
|
275
|
+
throw roleContractError("ROLE_STATUS_ENVELOPE_INVALID");
|
|
276
|
+
}
|
|
277
|
+
if (typeof projectId !== "string" || !PROJECT_ID_RE.test(projectId)) {
|
|
278
|
+
throw roleContractError("SCOPE_REQUIRED");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const snapshot = envelope.items.find((item) => {
|
|
282
|
+
const key = item?.role_key;
|
|
283
|
+
return key?.project_id === projectId && key?.role_kind === "cto";
|
|
284
|
+
});
|
|
285
|
+
if (!snapshot) return { cto: null };
|
|
286
|
+
|
|
287
|
+
const status =
|
|
288
|
+
snapshot.state === "active"
|
|
289
|
+
? "active"
|
|
290
|
+
: snapshot.state === "electable" || snapshot.state === "elected-probing"
|
|
291
|
+
? "electable"
|
|
292
|
+
: "offline";
|
|
293
|
+
const legacy = snapshotLegacyDetails(snapshot);
|
|
294
|
+
return {
|
|
295
|
+
cto: {
|
|
296
|
+
role: "cto",
|
|
297
|
+
status,
|
|
298
|
+
leader_agent_id:
|
|
299
|
+
status === "active" ? (snapshot.holder_agent_id ?? null) : null,
|
|
300
|
+
previous_leader_agent_id: snapshot.previous_holder_agent_id ?? null,
|
|
301
|
+
leader_epoch: snapshot.epoch,
|
|
302
|
+
lease_expires_ms:
|
|
303
|
+
status === "active" ? (snapshot.lease_expires_ms ?? null) : null,
|
|
304
|
+
candidate_source: legacy.candidate_source,
|
|
305
|
+
candidate_count: snapshot.candidate_count,
|
|
306
|
+
live_candidate_count: snapshot.live_candidate_count,
|
|
307
|
+
pending_count: snapshot.pending_count,
|
|
308
|
+
transferred_count: legacy.transferred_count,
|
|
309
|
+
last_transition_ms: snapshot.last_transition_ms,
|
|
310
|
+
last_reason: snapshot.last_reason,
|
|
311
|
+
candidates: legacy.candidates,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const EVENT_NAMES = new Set(["capability.degraded", "drift.detected"]);
|
|
2
|
+
const CONTROL_ACTIONS = new Set([
|
|
3
|
+
"log_only",
|
|
4
|
+
"candidate_exclude",
|
|
5
|
+
"adapter_disable",
|
|
6
|
+
"role_failover",
|
|
7
|
+
"dispatch_block",
|
|
8
|
+
"manager_disable",
|
|
9
|
+
]);
|
|
10
|
+
const OPTIONAL_FIELDS = [
|
|
11
|
+
"project_id",
|
|
12
|
+
"role_key_wire",
|
|
13
|
+
"candidate_agent_id",
|
|
14
|
+
"transport_id",
|
|
15
|
+
"capability",
|
|
16
|
+
"expected_version",
|
|
17
|
+
"observed_version",
|
|
18
|
+
"trace_id",
|
|
19
|
+
"error_code",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const ROLE_CONTROL_EVENT_SCHEMA_VERSION = "tfx.role-control-event.v1";
|
|
23
|
+
export const ROLE_CONTROL_EVENT_FIELDS = Object.freeze([
|
|
24
|
+
"schema_version",
|
|
25
|
+
"event",
|
|
26
|
+
"event_id",
|
|
27
|
+
"module",
|
|
28
|
+
"dur_ms",
|
|
29
|
+
"control_action",
|
|
30
|
+
"reason_code",
|
|
31
|
+
...OPTIONAL_FIELDS,
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function eventError(code, message = code) {
|
|
35
|
+
const error = new Error(message);
|
|
36
|
+
error.code = code;
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultEventId() {
|
|
41
|
+
return (
|
|
42
|
+
globalThis.crypto?.randomUUID?.() ??
|
|
43
|
+
`evt_${Math.random().toString(36).slice(2)}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateRequired(event) {
|
|
48
|
+
if (!EVENT_NAMES.has(event.event))
|
|
49
|
+
throw eventError("ROLE_CONTROL_EVENT_INVALID");
|
|
50
|
+
if (typeof event.event_id !== "string" || !event.event_id) {
|
|
51
|
+
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
52
|
+
}
|
|
53
|
+
if (typeof event.module !== "string" || !event.module) {
|
|
54
|
+
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isFinite(event.dur_ms) || event.dur_ms < 0) {
|
|
57
|
+
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
58
|
+
}
|
|
59
|
+
if (!CONTROL_ACTIONS.has(event.control_action)) {
|
|
60
|
+
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
61
|
+
}
|
|
62
|
+
if (typeof event.reason_code !== "string" || !event.reason_code) {
|
|
63
|
+
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build an allowlisted event object. Unknown domain fields are intentionally
|
|
69
|
+
* discarded before the event reaches a logger.
|
|
70
|
+
*/
|
|
71
|
+
export function buildRoleControlEvent(
|
|
72
|
+
input = {},
|
|
73
|
+
{ eventId = defaultEventId } = {},
|
|
74
|
+
) {
|
|
75
|
+
const event = {
|
|
76
|
+
schema_version: ROLE_CONTROL_EVENT_SCHEMA_VERSION,
|
|
77
|
+
event: input.event,
|
|
78
|
+
event_id: input.event_id ?? eventId(),
|
|
79
|
+
module: input.module,
|
|
80
|
+
dur_ms: input.dur_ms,
|
|
81
|
+
control_action: input.control_action,
|
|
82
|
+
reason_code: input.reason_code,
|
|
83
|
+
};
|
|
84
|
+
for (const field of OPTIONAL_FIELDS) {
|
|
85
|
+
if (input[field] !== undefined) event[field] = input[field];
|
|
86
|
+
}
|
|
87
|
+
validateRequired(event);
|
|
88
|
+
return event;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildCapabilityDegradedEvent(input = {}, options = {}) {
|
|
92
|
+
return buildRoleControlEvent(
|
|
93
|
+
{ ...input, event: "capability.degraded" },
|
|
94
|
+
options,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildDriftDetectedEvent(input = {}, options = {}) {
|
|
99
|
+
return buildRoleControlEvent({ ...input, event: "drift.detected" }, options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pino-style loggers receive event as an object field and as the message.
|
|
104
|
+
*/
|
|
105
|
+
export function emitRoleControlEvent(log, event, level = "warn") {
|
|
106
|
+
if (!log || typeof log[level] !== "function") {
|
|
107
|
+
throw eventError("ROLE_CONTROL_LOGGER_INVALID");
|
|
108
|
+
}
|
|
109
|
+
const sanitized = buildRoleControlEvent(event, {
|
|
110
|
+
eventId: () => event?.event_id,
|
|
111
|
+
});
|
|
112
|
+
log[level](sanitized, sanitized.event);
|
|
113
|
+
}
|