feat(ontology): add core service catalog
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user