281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
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.runtimeInstanceId) refs.runtime_instance_id = String(workflow.runtimeInstanceId)
|
||
|
||
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)
|
||
})
|
||
}
|