feat(ontology): add core service catalog

This commit is contained in:
Codex
2026-06-20 12:54:03 +03:00
parent 83cca4224b
commit 2d5fef3948
59 changed files with 7878 additions and 0 deletions
@@ -0,0 +1,199 @@
import { resolveAssistantAction } from '../assistant-action-resolver.mjs'
const HUB_ACTIONS = new Set([
'hub.user.read_admin_summary',
'hub.invite.list_pending',
'hub.access_request.list_pending',
'hub.user.block',
'hub.user.unblock',
'hub.membership.change_role',
'hub.membership.disable',
'hub.assistant_access.change_role',
])
const MEMBERSHIP_ROLES = new Set(['client_owner', 'client_admin', 'member'])
const CORE_ASSISTANT_ROLES = new Set(['blocked', 'member', 'admin'])
function requireValue(input, key) {
const value = input[key]
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`Missing required adapter input: ${key}`)
}
return String(value)
}
function encode(value) {
return encodeURIComponent(String(value))
}
function makeIdempotencyKey(actionId, input) {
if (input.idempotencyKey) return String(input.idempotencyKey)
return [
actionId,
input.actorUserId || 'actor_unknown',
input.targetUserId || input.membershipId || 'target_unknown',
input.targetRole || input.targetAssistantRole || input.targetStatus || 'default',
].join(':')
}
function patchPlan(actionId, input, path, body, summary) {
return {
method: 'PATCH',
path,
body,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': makeIdempotencyKey(actionId, input),
},
summary,
}
}
function buildPlanForAction(actionId, input) {
switch (actionId) {
case 'hub.user.read_admin_summary':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'users,memberships,services,grants,exceptions,invites,accessRequests',
clientId: input.clientId || null,
},
summary: 'Read admin-visible Launcher control-plane summary and filter in adapter output.',
}
case 'hub.invite.list_pending':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'invites',
status: 'pending',
clientId: input.clientId || null,
},
summary: 'Read Launcher invites and return pending invites visible in current admin scope.',
}
case 'hub.access_request.list_pending':
return {
method: 'GET',
path: '/api/admin/control-plane',
query: {
projection: 'accessRequests,engineWorkflowAccessRequests,users,clients',
status: 'pending',
clientId: input.clientId || null,
},
summary: 'Read Launcher access requests and return pending requests visible in current admin scope.',
}
case 'hub.user.block':
return patchPlan(
actionId,
input,
`/api/admin/users/${encode(requireValue(input, 'targetUserId'))}/profile`,
{ globalStatus: 'blocked' },
'Block Launcher user via guarded profile update route.',
)
case 'hub.user.unblock':
return patchPlan(
actionId,
input,
`/api/admin/users/${encode(requireValue(input, 'targetUserId'))}/profile`,
{ globalStatus: 'active' },
'Unblock Launcher user via guarded profile update route.',
)
case 'hub.membership.change_role': {
const targetRole = requireValue(input, 'targetRole')
if (!MEMBERSHIP_ROLES.has(targetRole)) {
throw new Error(`Invalid targetRole: ${targetRole}`)
}
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ role: targetRole },
'Change Launcher client membership role via guarded membership update route.',
)
}
case 'hub.membership.disable':
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ status: 'disabled' },
'Disable Launcher client membership via guarded membership update route.',
)
case 'hub.assistant_access.change_role': {
const targetAssistantRole = requireValue(input, 'targetAssistantRole')
if (!CORE_ASSISTANT_ROLES.has(targetAssistantRole)) {
throw new Error(`Invalid targetAssistantRole: ${targetAssistantRole}`)
}
return patchPlan(
actionId,
input,
`/api/admin/memberships/${encode(requireValue(input, 'membershipId'))}`,
{ coreAssistantRole: targetAssistantRole },
'Change NDC Core Assistant access role via guarded membership update route.',
)
}
default:
throw new Error(`Unsupported HUB action: ${actionId}`)
}
}
export async function buildHubLauncherAdminPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const actionId = decision.action?.id
if (!actionId) {
return {
ok: false,
decision: 'unsupported_hub_action',
reason: 'Input did not resolve to a supported HUB Launcher admin action.',
actionDecision: decision,
}
}
if (
decision.decision !== 'allowed' &&
!(options.planPreview === true && decision.decision === 'needs_confirmation')
) {
return {
ok: false,
decision: decision.decision,
reason: decision.reason,
actionDecision: decision,
}
}
if (!HUB_ACTIONS.has(actionId)) {
return {
ok: false,
decision: 'unsupported_hub_action',
reason: 'Input did not resolve to a supported HUB Launcher admin action.',
actionDecision: decision,
}
}
const plan = buildPlanForAction(actionId, input)
const adapterReady = decision.action.adapterStatus === 'implemented'
return {
ok: true,
decision: 'planned',
adapterId: 'adapter.hub.launcher_admin_api',
adapterStatus: decision.action.adapterStatus,
executionReady: adapterReady,
dryRunOnly: !adapterReady,
gatewayActor: {
userId: input.actorUserId || null,
email: input.actorEmail || null,
subject: input.actorSubject || null,
},
safety: {
noDeleteRouteMapped: true,
requiresExternalExecutor: true,
sourceGuardedRoutesOnly: true,
confirmationAlreadyChecked: decision.execution.confirmed || options.planPreview === true,
},
request: plan,
actionDecision: decision,
}
}
@@ -0,0 +1,303 @@
import { createHash } from 'node:crypto'
import { resolveAssistantAction } from '../assistant-action-resolver.mjs'
const OPS_PRODUCT_API_ADAPTER_ID = 'adapter.ops.product_api'
const OPS_ACTIONS = new Set([
'ops.card.list_recent',
'ops.card.create',
'ops.card.add_comment',
])
const OPS_CARD_PRIORITIES = new Set(['none', 'low', 'medium', 'high', 'urgent'])
function cleanString(value) {
return String(value || '').trim()
}
function cleanText(value, max = 4000) {
const text = cleanString(value)
if (!text) return ''
return text.length > max ? text.slice(0, max).trim() : text
}
function cleanLimit(value) {
const number = Number(value)
if (!Number.isFinite(number)) return 5
return Math.min(20, Math.max(1, Math.trunc(number)))
}
function firstString(...values) {
for (const value of values) {
const text = cleanString(value)
if (text) return text
}
return ''
}
function normalizeProjectQuery(input = {}) {
return {
workspaceSlug: firstString(
input.workspaceSlug,
input.workspace_slug,
input.opsWorkspaceSlug,
input.ops_workspace_slug,
input.context?.workspaceSlug,
input.context?.opsWorkspaceSlug,
),
projectId: firstString(
input.projectId,
input.project_id,
input.opsProjectId,
input.ops_project_id,
input.context?.projectId,
input.context?.opsProjectId,
),
}
}
function cleanPriority(value) {
const text = cleanString(value).toLowerCase()
return OPS_CARD_PRIORITIES.has(text) ? text : ''
}
function cleanIssueId(value) {
return cleanString(value).replace(/^#/, '')
}
function idempotencyKeyFor(actionId, input, fallbackParts = {}) {
const explicit = firstString(
input.idempotencyKey,
input.idempotency_key,
input.context?.idempotencyKey,
input.context?.idempotency_key,
)
if (explicit) return explicit
const digest = createHash('sha256')
.update(JSON.stringify({
actionId,
actorUserId: input.actorUserId || null,
actorEmail: input.actorEmail || null,
...fallbackParts,
}))
.digest('hex')
.slice(0, 24)
return `ai-workspace:${actionId}:${digest}`
}
function buildPlanForAction(actionId, input = {}, options = {}) {
switch (actionId) {
case 'ops.card.list_recent': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const limit = cleanLimit(input.limit)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for reading cards.',
}
}
return {
ok: true,
request: {
method: 'GET',
path: '/api/v1/tools/issues',
query: {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
...(cleanString(input.query || input.search) ? { query: cleanString(input.query || input.search) } : {}),
},
summary: 'Read recent OPS Product cards through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
limit,
},
}
}
case 'ops.card.create': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const title = cleanText(
firstString(input.title, input.name, input.cardTitle, input.taskTitle, input.summary),
260,
)
const description = cleanText(
firstString(input.description, input.body, input.details, input.text, input.message),
12000,
)
const priority = cleanPriority(input.priority)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for creating a card.',
}
}
if (!title) {
return {
ok: false,
decision: 'ops_card_title_missing',
reason: 'OPS card title is required.',
}
}
const body = {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
title,
...(description ? { description } : {}),
...(priority ? { priority } : {}),
}
return {
ok: true,
request: {
method: 'POST',
path: '/api/v1/tools/issues',
headers: {
'Idempotency-Key': idempotencyKeyFor(actionId, input, body),
},
body,
summary: 'Create OPS Product card through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
},
}
}
case 'ops.card.add_comment': {
const normalized = normalizeProjectQuery(input)
const workspaceSlug = normalized.workspaceSlug || cleanString(options.defaultOpsWorkspaceSlug)
const projectId = normalized.projectId || cleanString(options.defaultOpsProjectId)
const issueId = cleanIssueId(firstString(
input.issueId,
input.issue_id,
input.cardId,
input.card_id,
input.targetCardId,
input.target_card_id,
input.context?.issueId,
input.context?.issue_id,
input.context?.cardId,
input.context?.card_id,
))
const bodyText = cleanText(
firstString(input.body, input.comment, input.text, input.message),
12000,
)
if (!projectId) {
return {
ok: false,
decision: 'ops_project_context_missing',
reason: 'OPS project id is required for commenting on a card.',
}
}
if (!issueId) {
return {
ok: false,
decision: 'ops_issue_id_missing',
reason: 'OPS issue id is required for commenting on a card.',
}
}
if (!bodyText) {
return {
ok: false,
decision: 'ops_comment_body_missing',
reason: 'OPS comment body is required.',
}
}
const body = {
project_id: projectId,
...(workspaceSlug ? { workspace_slug: workspaceSlug } : {}),
body: bodyText,
}
return {
ok: true,
request: {
method: 'POST',
path: `/api/v1/tools/issues/${encodeURIComponent(issueId)}/comments`,
headers: {
'Idempotency-Key': idempotencyKeyFor(actionId, input, { issueId, ...body }),
},
body,
summary: 'Append comment to OPS Product card through local Ops Gateway REST tool route.',
},
opsContext: {
workspaceSlug,
projectId,
issueId,
},
}
}
default:
return {
ok: false,
decision: 'unsupported_ops_action',
reason: `Unsupported OPS action: ${actionId}`,
}
}
}
export async function buildOpsProductPlan(input = {}, options = {}) {
const decision = options.decision || await resolveAssistantAction(input, options)
const actionId = decision.action?.id
if (!actionId || !OPS_ACTIONS.has(actionId)) {
return {
ok: false,
decision: 'unsupported_ops_action',
reason: 'Input did not resolve to a supported OPS Product action.',
actionDecision: decision,
}
}
if (decision.decision !== 'allowed') {
return {
ok: false,
decision: decision.decision,
reason: decision.reason,
actionDecision: decision,
}
}
const actionPlan = buildPlanForAction(actionId, input, options)
if (!actionPlan.ok) {
return {
...actionPlan,
actionDecision: decision,
}
}
return {
ok: true,
decision: 'planned',
adapterId: OPS_PRODUCT_API_ADAPTER_ID,
adapterStatus: decision.action.adapterStatus,
executionReady: decision.execution?.executionReady === true,
dryRunOnly: decision.action.adapterStatus !== 'implemented',
actionDecision: decision,
gatewayActor: {
userId: input.actorUserId || null,
email: input.actorEmail || null,
subject: input.actorSubject || null,
},
request: actionPlan.request,
opsContext: actionPlan.opsContext,
safety: {
noDeleteRouteMapped: true,
sourceGuardedRoutesOnly: true,
confirmationAlreadyChecked: true,
},
}
}