148 lines
16 KiB
TypeScript
148 lines
16 KiB
TypeScript
import { assertNoActiveRoutingProfileOverlap } from "./routing-controller-overlap.js"
|
|
import { fingerprint } from "./security.js"
|
|
import { prepareRoutingControllerBaseline, assertRoutingControllerBaseline, routingControllerBaselinePrincipal, advanceRoutingControllerBaseline, type RoutingControllerBaseline } from "./routing-controller-baseline.js"
|
|
import { publicRoutingControllerState } from "./routing-community-public.js"
|
|
import { randomUUID } from "node:crypto"
|
|
import { z } from "zod"
|
|
import { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
|
import { pool } from "../../db/index.js"
|
|
import { registerMcpService } from "./service-registry.js"
|
|
import { accessGeneration, McpError } from "./security.js"
|
|
import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
|
|
import { decideRoutingSwitch, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js"
|
|
import type { add } from "./actions.js"
|
|
import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js"
|
|
import { controllerProfileCurrent, publishControllerState, saveControllerProfileCas, withControllerProfileLease } from "./routing-controller-fence.js"
|
|
import { assertIdentifiedRoutingCompletion, createRoutingReservationStore } from "./routing-reservation-store.js"
|
|
|
|
const targetIds = (profile: RouteAiProfile) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])]
|
|
function assertControllerCanBeEnabled(profile: RouteAiProfile) {
|
|
const reason = routeAiControllerBlockReason(profile)
|
|
if (reason) throw new McpError("UNSUPPORTED_CAPABILITY", reason, 409)
|
|
}
|
|
export function registerRoutingProfileActions(register: typeof add) {
|
|
register("routing.profiles.read", "mm_route_profiles_list", "Профили и состояние контроллера Route AI", "network", "mm:network:read", "read", "connection", z.object({}).strict(), {
|
|
read: async (_input, context) => ({ profiles: (await pool.query("SELECT id,version,config,state,enabled,grant_expires_at,updated_at FROM mcp_routing_profiles WHERE connection_id=$1 ORDER BY updated_at DESC LIMIT 200", [context.connection.id])).rows.filter(row => targetIds(routeAiProfileSchema.parse(row.config)).every(id => context.connection.policy.scope.serverIds.includes(id))).map(row=>({...row,state:publicRoutingControllerState(row.state)})) }),
|
|
})
|
|
registerMcpService("routing.profile.save", {
|
|
current: async input => (await pool.query("SELECT id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows[0] ?? null,
|
|
execute: async input => {
|
|
if (input.enabled) assertControllerCanBeEnabled(routeAiProfileSchema.parse(input.profile))
|
|
if (input.expectedVersion !== null && (!Number.isSafeInteger(input.expectedVersion) || Number(input.expectedVersion) < 1)) throw new McpError("PLAN_EXPIRED", "План не содержит ожидаемую версию профиля", 409)
|
|
if (input.enabled) {
|
|
const principal=await routingControllerBaselinePrincipal(input.baseline),connection=await validatePrincipal(principal)
|
|
await assertRoutingControllerBaseline(input.baseline,String(input.profileId),input.expectedVersion===null?1:Number(input.expectedVersion)+1,input.profile,String(input.expiresAt),{principal,connection})
|
|
await assertNoActiveRoutingProfileOverlap(String(input.profileId),input.profile)
|
|
}
|
|
const row = await saveControllerProfileCas(pool, input)
|
|
if (!row) throw new McpError("STATE_CONFLICT", "Профиль изменён после подготовки плана; нужен новый план", 409)
|
|
return row
|
|
},
|
|
verify: async (input, result) => !!result && (await pool.query("SELECT config,state,enabled,version,credential_id,generation,policy_version,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows.some(row => row.version === (result as { version: number }).version && row.version === (input.expectedVersion === null ? 1 : Number(input.expectedVersion) + 1) && JSON.stringify(routeAiProfileSchema.parse(row.config)) === JSON.stringify(routeAiProfileSchema.parse(input.profile)) && row.enabled === input.enabled && (!input.enabled || fingerprint(row.state.baseline)===fingerprint(input.baseline)) && row.credential_id === input.credentialId && row.generation === input.generation && row.policy_version === input.policyVersion && row.grant_expires_at.getTime() === Date.parse(String(input.expiresAt))),
|
|
})
|
|
register("routing.profiles.save", "mm_route_profile_save_plan", "Сохранить профиль и временный допуск контроллера", "network", "mm:network:write", "write", "servers", z.object({ profileId: z.string().uuid().optional(), profile: routeAiProfileSchema, enabled: z.boolean().default(false), expiresAt: z.string().datetime({ offset: true }) }).strict(), {
|
|
target: input => ({ scope: "servers", serverIds: targetIds(routeAiProfileSchema.parse(input.profile)) }),
|
|
prepare: async (input, context) => {
|
|
const profile = routeAiProfileSchema.parse(input.profile), ids = targetIds(profile), profileId = input.profileId ?? randomUUID()
|
|
if (input.enabled) assertControllerCanBeEnabled(profile)
|
|
const expires = Date.parse(String(input.expiresAt)), credentialExpires = Date.parse(context.principal.credentialExpiresAt)
|
|
if (expires <= Date.now() || expires > Math.min(Date.now() + 86400000, credentialExpires, Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime())) throw new McpError("INVALID_EXPIRATION", "Допуск контроллера ограничен 24 часами и сроком подтверждения прав владельца", 400)
|
|
const existing = (await pool.query("SELECT id,connection_id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0]
|
|
if (existing && existing.connection_id !== context.connection.id) throw new McpError("NOT_FOUND", "Профиль не найден", 404)
|
|
if (input.enabled && !["routing.analyze", "routing.community.switch"].every(id => context.connection.policy.scope.operationIds.includes(id))) throw new McpError("OPERATION_DENIED", "Для контроллера нужны отдельные разрешения анализа и переключения")
|
|
if(input.enabled)await assertNoActiveRoutingProfileOverlap(String(profileId),profile)
|
|
const baseline=input.enabled?await prepareRoutingControllerBaseline(String(profileId),(existing?.version??0)+1,profile,String(input.expiresAt),context):undefined
|
|
const { connection_id: _connection, ...before } = existing ?? {}
|
|
return { target: { scope: "servers", serverIds: ids }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: existing ? before : null,
|
|
steps: [{ kind: "service", service: "routing.profile.save", input: { ...input, profileId, expectedVersion: existing?.version ?? null, connectionId: context.connection.id, baseline, credentialId: context.principal.credentialId, generation: await accessGeneration(), policyVersion: context.connection.policy.version } }], requiresHuman: true,
|
|
warnings: ["Контроллер использует исходный доступ, лимиты и подтверждения. Отзыв токена или изменение политики останавливает его.", "При неизвестном пути или результате записи контроллер приостанавливается."] }
|
|
},
|
|
version: 4,
|
|
})
|
|
}
|
|
|
|
interface PersistentController extends ControllerState { baseline?:RoutingControllerBaseline; planId?: string; candidateId?: string; expectedCandidateId?: string; reason?: string; checkedAt?: string; probedAt?: string }
|
|
/** Also fenced per profile so direct/concurrent ticks cannot publish stale state. */
|
|
export async function tickRoutingProfiles() {
|
|
const { invokeMcpTool, applyPlan } = await import("./runtime.js")
|
|
const { rows } = await pool.query("SELECT * FROM mcp_routing_profiles WHERE enabled ORDER BY updated_at LIMIT 100")
|
|
for (const listed of rows) {
|
|
await withControllerProfileLease(pool, listed.id, async lease => {
|
|
const { rows: [row] } = await lease.query("SELECT * FROM mcp_routing_profiles WHERE id=$1 AND enabled", [listed.id])
|
|
if (!row) return
|
|
const profile = routeAiProfileSchema.parse(row.config), state: PersistentController = row.state
|
|
const next: PersistentController = { ...state, checkedAt: new Date().toISOString() }
|
|
const stop = async (reason: string) => {
|
|
next.pausedReason = reason; next.reason = reason
|
|
if (await publishControllerState(lease, row, next, true)) await audit(lease, "route-controller", "controller.pause", row.connection_id, { profileId: row.id, reason })
|
|
}
|
|
const current = async () => { if (!await controllerProfileCurrent(lease, row)) throw new McpError("STATE_CONFLICT", "Допуск профиля изменился во время цикла", 409) }
|
|
try {
|
|
const controllerBlock = routeAiControllerBlockReason(profile)
|
|
if (controllerBlock) { await stop(controllerBlock); return }
|
|
if (row.grant_expires_at.getTime() <= Date.now() || row.generation !== await accessGeneration() || !row.credential_id) { await stop("Допуск контроллера истёк или отозван"); return }
|
|
const principal: McpPrincipal = await routingControllerBaselinePrincipal(state.baseline)
|
|
const connection = await validatePrincipal(principal)
|
|
if (connection.policy.version !== row.policy_version) { await stop("Политика изменилась; требуется новый допуск"); return }
|
|
if (state.pausedReason) { await stop(state.pausedReason); return }
|
|
if (state.planId) {
|
|
const { rows: [plan] } = await pool.query("SELECT p.state,p.expires_at,o.id AS operation_id,o.state AS operation_state FROM mcp_plans p LEFT JOIN mcp_operations o ON o.plan_id=p.id WHERE p.id=$1 AND p.connection_id=$2", [state.planId, connection.id])
|
|
if (!plan || ["rejected", "unknown", "partial"].includes(plan.operation_state) || ["cancelled", "invalidated", "expired"].includes(plan.state)) { await stop("Изменение отклонено или требует разбора результата"); return }
|
|
if (plan.operation_state === "applied") {
|
|
const at = new Date().toISOString()
|
|
await assertIdentifiedRoutingCompletion(lease, row.id, state.planId, plan.operation_id, principal)
|
|
next.baseline=await advanceRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection},state.planId,plan.operation_id)
|
|
next.lastSwitchAt = at; next.switches = [...(state.switches ?? [])]; next.consecutive = 0
|
|
next.expectedCandidateId = state.candidateId
|
|
delete next.planId; delete next.candidateId; delete next.pendingCandidateId; delete next.operationId
|
|
next.reason = "Изменение конфигурации проверено; ожидаются новые измерения сети"
|
|
} else if (plan.operation_id) next.reason = "Операция выполняется"
|
|
else if (plan.expires_at.getTime() <= Date.now()) { await stop("План истёк без подтверждения"); return }
|
|
else if (["approved", "draft"].includes(plan.state)) {
|
|
await current()
|
|
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
|
const operation = await applyPlan(principal, state.planId, `controller:${row.id}:${state.planId}`)
|
|
next.operationId = operation.id; next.reason = "Применение через общий исполнитель"
|
|
} else next.reason = "Ожидается подтверждение человеком"
|
|
} else {
|
|
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
|
if (state.probedAt && Date.now() - Date.parse(state.probedAt) < 55000) return
|
|
await current()
|
|
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile }) as { ranking: ReturnType<typeof rankRoutingCandidates>; currentCandidateId: string | null }
|
|
if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return }
|
|
if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId
|
|
const health: PathHealth[] = []
|
|
const selected = [analysis.currentCandidateId, ...analysis.ranking.filter(value => value.eligible).slice(0, 2).map(value => value.candidate.id)]
|
|
if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return }
|
|
for (const candidateId of [...new Set(selected)].filter((id): id is string => !!id)) {
|
|
await current()
|
|
if (profile.candidates.find(candidate => candidate.id === candidateId)?.controlProbe) health.push(await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId }) as PathHealth)
|
|
}
|
|
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
|
const charges = await createRoutingReservationStore().readHourlyCharges(row.id, principal)
|
|
await current()
|
|
const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, Date.now(), health, charges.totalCharges)
|
|
Object.assign(next, decision.state, { reason: decision.reason })
|
|
next.probedAt = new Date().toISOString()
|
|
if (decision.state.pausedReason) { await stop(decision.state.pausedReason); return }
|
|
const hasFreshSelectedProbe = health.some(probe => {
|
|
if (probe.candidateId !== decision.switchTo || probe.status !== "passed") return false
|
|
const age = Date.now() - Date.parse(probe.observedAt)
|
|
return age >= 0 && age < COMMUNITY_PATH_MAX_AGE_MS
|
|
})
|
|
if (decision.switchTo && usesScopedRouteAiContext(profile) && !hasFreshSelectedProbe) {
|
|
next.reason = "Ожидается успешная контрольная проба выбранного пути"
|
|
} else if (decision.switchTo) {
|
|
await current()
|
|
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
|
|
const prepared = await invokeMcpTool(principal, "mm_route_optimizer_plan", { profile, candidateId: decision.switchTo }, undefined, { persistentRoutingProfile: { id: row.id, version: row.version } }) as { planId: string }
|
|
next.planId = prepared.planId; next.candidateId = decision.switchTo; next.reason = "Подготовлен план переключения"
|
|
await audit(pool, "route-controller", "controller.plan", connection.id, { profileId: row.id, planId: prepared.planId, candidateId: decision.switchTo })
|
|
}
|
|
}
|
|
await publishControllerState(lease, row, next)
|
|
} catch (error) { await stop(error instanceof McpError ? error.code : "Проверка состояния недоступна") }
|
|
})
|
|
}
|
|
}
|