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
+280
View File
@@ -0,0 +1,280 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadCatalog, serviceRoot } from './catalog.mjs'
import { validateCatalog } from './validate.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const contextBindingsPath = path.resolve(__dirname, '..', 'catalog', 'context-bindings.json')
function usage() {
return [
'Usage:',
' node src/registry.mjs list-contexts',
' node src/registry.mjs list-bindings',
' node src/registry.mjs add-context --input-json examples/engine-context.example.json [--dry-run]',
' node src/registry.mjs add-binding --input-json examples/ops-engine-binding.example.json [--dry-run]',
' node src/registry.mjs import-engine-context --input-json examples/engine-workflow-manifest.example.json [--dry-run]',
].join('\n')
}
function argValue(name) {
const index = process.argv.indexOf(name)
if (index < 0) return ''
return process.argv[index + 1] || ''
}
async function readInputJson() {
const inputPath = argValue('--input-json')
const inputRaw = argValue('--input')
if (inputRaw) return JSON.parse(inputRaw)
if (!inputPath) throw new Error('missing --input-json or --input')
const fullPath = path.resolve(process.cwd(), inputPath)
return JSON.parse(await fs.readFile(fullPath, 'utf8'))
}
async function readContextBindings() {
return JSON.parse(await fs.readFile(contextBindingsPath, 'utf8'))
}
async function writeContextBindings(next) {
const raw = `${JSON.stringify(next, null, 2)}\n`
await fs.writeFile(contextBindingsPath, raw, 'utf8')
}
function required(value, label) {
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`${label} is required`)
}
}
function isObject(value) {
return value && typeof value === 'object' && !Array.isArray(value)
}
function slugPart(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9а-яё]+/giu, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'unknown'
}
function validateDraftContext(draft, catalog, registry) {
required(draft.id, 'context.id')
required(draft.surface, 'context.surface')
required(draft.entityId, 'context.entityId')
required(draft.sourceSystem, 'context.sourceSystem')
required(draft.label, 'context.label')
required(draft.status, 'context.status')
if (!catalog.entityById.has(draft.entityId)) throw new Error(`unknown context.entityId: ${draft.entityId}`)
if (!registry.contextStatuses.includes(draft.status)) throw new Error(`invalid context.status: ${draft.status}`)
if (!isObject(draft.refs)) throw new Error('context.refs must be an object')
if (draft.parentContextId && !catalog.contextById.has(draft.parentContextId)) {
throw new Error(`unknown context.parentContextId: ${draft.parentContextId}`)
}
}
function validateDraftBinding(draft, catalog, registry) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!catalog.contextById.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!catalog.contextById.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function validateDraftBindingWithKnownContextIds(draft, catalog, registry, knownContextIds) {
required(draft.id, 'binding.id')
required(draft.typeId, 'binding.typeId')
required(draft.status, 'binding.status')
required(draft.fromContextId, 'binding.fromContextId')
required(draft.toContextId, 'binding.toContextId')
if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`)
if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`)
if (!knownContextIds.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`)
if (!knownContextIds.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`)
}
function upsertById(list, item) {
const index = list.findIndex((existing) => existing.id === item.id)
if (index >= 0) {
const next = [...list]
next[index] = item
return { list: next, action: 'updated' }
}
return { list: [...list, item], action: 'created' }
}
async function updateRegistry(mutator, dryRun) {
const beforeRaw = await fs.readFile(contextBindingsPath, 'utf8')
const registry = JSON.parse(beforeRaw)
const next = structuredClone(registry)
const result = mutator(next)
if (dryRun) return { dryRun: true, ...result, registry: next }
await writeContextBindings(next)
const validation = await validateCatalog()
if (!validation.ok) {
await fs.writeFile(contextBindingsPath, beforeRaw, 'utf8')
const err = new Error('registry validation failed; reverted context-bindings.json')
err.validation = validation
throw err
}
return { dryRun: false, ...result, validation: validation.counts }
}
async function listContexts() {
const catalog = await loadCatalog()
return catalog.contextBindings.contexts.map((context) => ({
id: context.id,
surface: context.surface,
entityId: context.entityId,
sourceSystem: context.sourceSystem,
label: context.label,
status: context.status,
refs: context.refs,
parentContextId: context.parentContextId || null,
}))
}
async function listBindings() {
const catalog = await loadCatalog()
return catalog.contextBindings.bindings.map((binding) => ({
id: binding.id,
typeId: binding.typeId,
status: binding.status,
fromContextId: binding.fromContextId,
toContextId: binding.toContextId,
summary: binding.summary || '',
}))
}
async function addContext(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftContext(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.contexts, draft)
next.contexts = out.list
return { action: out.action, context: draft }
}, dryRun)
}
async function addBinding(dryRun) {
const draft = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
validateDraftBinding(draft, catalog, registry)
return updateRegistry((next) => {
const out = upsertById(next.bindings, draft)
next.bindings = out.list
return { action: out.action, binding: draft }
}, dryRun)
}
function buildEngineContextFromManifest(manifest) {
if (!isObject(manifest)) throw new Error('manifest must be an object')
const workflow = manifest.workflow || {}
required(workflow.id, 'workflow.id')
required(workflow.label, 'workflow.label')
const refs = {
workflow_id: String(workflow.id),
}
if (workflow.nodeId) refs.node_id = String(workflow.nodeId)
if (workflow.runtimeWorkflowId) refs.runtime_workflow_id = String(workflow.runtimeWorkflowId)
if (workflow.n8nInstanceId) refs.n8n_instance_id = String(workflow.n8nInstanceId)
return {
id: `context.engine.${slugPart(workflow.id)}`,
surface: 'engine',
entityId: workflow.runtimeWorkflowId ? 'engine.workflow_l2' : 'engine.workflow_l1',
sourceSystem: String(manifest.context?.sourceSystem || 'nodedc-engine'),
label: String(workflow.label),
status: String(manifest.context?.status || 'planned'),
refs,
}
}
function buildOpsEngineBindingFromManifest(manifest, engineContext) {
const bind = manifest.bindToOps || {}
if (bind.enabled !== true) return null
required(bind.fromContextId, 'bindToOps.fromContextId')
return {
id: `binding.ops.${slugPart(bind.fromContextId)}.engine.${slugPart(engineContext.id)}`,
typeId: 'binding_type.ops_card_to_engine_workflow',
status: String(bind.status || 'planned'),
fromContextId: String(bind.fromContextId),
toContextId: engineContext.id,
summary: String(bind.summary || `Binds ${bind.fromContextId} to ${engineContext.id}.`),
}
}
async function importEngineContext(dryRun) {
const manifest = await readInputJson()
const catalog = await loadCatalog()
const registry = await readContextBindings()
const engineContext = buildEngineContextFromManifest(manifest)
const binding = buildOpsEngineBindingFromManifest(manifest, engineContext)
validateDraftContext(engineContext, catalog, registry)
if (binding) {
const knownContextIds = new Set([
...catalog.contextById.keys(),
engineContext.id,
])
validateDraftBindingWithKnownContextIds(binding, catalog, registry, knownContextIds)
}
return updateRegistry((next) => {
const contextOut = upsertById(next.contexts, engineContext)
next.contexts = contextOut.list
let bindingOut = null
if (binding) {
bindingOut = upsertById(next.bindings, binding)
next.bindings = bindingOut.list
}
return {
action: bindingOut ? `${contextOut.action}_context_${bindingOut.action}_binding` : `${contextOut.action}_context`,
context: engineContext,
binding,
}
}, dryRun)
}
async function main() {
const command = process.argv[2]
const dryRun = process.argv.includes('--dry-run')
let output
if (command === 'list-contexts') output = await listContexts()
else if (command === 'list-bindings') output = await listBindings()
else if (command === 'add-context') output = await addContext(dryRun)
else if (command === 'add-binding') output = await addBinding(dryRun)
else if (command === 'import-engine-context') output = await importEngineContext(dryRun)
else {
console.error(usage())
process.exit(2)
}
console.log(JSON.stringify({ ok: true, serviceRoot, command, output }, null, 2))
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(JSON.stringify({
ok: false,
error: err.message || String(err),
validation: err.validation || undefined,
}, null, 2))
process.exit(1)
})
}