godcrm/backend/services/AgentVersionService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

223 lines
7.8 KiB
JavaScript

// AgentVersionService — ADR-0007-S G1-data write path.
//
// CRUD + promote/retire handlers for the `agent_versions` registry (migration
// 073). This is the substrate Smith rev-3 (G2) writes to when it mints /
// promotes / retires `@smith-*` agent revisions.
//
// The single-active invariant ("at most one 'active' revision per agent") is
// enforced TWICE on purpose:
// 1. DB level — partial unique index uq_agent_versions_one_active (the backstop).
// 2. Service level — promoteAgentVersion() retires the prior active row and
// activates the new one inside ONE transaction, retire-before-activate, so
// the index is never transiently violated and the flip is all-or-nothing.
//
// Agents are rows in the universal-tables store (table_rows, table_id = 1784),
// so `agent_row_id` is an FK into table_rows. The FK rejects a non-existent row
// id; assertAgentRow() additionally rejects a row that exists but is not an
// agent (wrong table_id).
import { dbGet, dbAll, dbRun, withTransactionAsync } from '../database/connection.js';
export const AGENTS_TABLE_ID = 1784;
export const VERSION_STATUSES = ['draft', 'active', 'retired'];
/**
* Verify `agentRowId` points at a real AI-Agents row (table_id = 1784).
* Throws a tagged Error (`code: 'AGENT_NOT_FOUND'`) otherwise. The DB FK also
* guards non-existent ids, but this gives a clean, typed error and catches the
* "exists but is not an agent" case the FK cannot distinguish.
* @param {{get: Function}} [exec] - optional trx executor; defaults to dbGet
*/
export async function assertAgentRow(agentRowId, exec) {
const getOne = exec ? exec.get.bind(exec) : dbGet;
const row = await getOne(
'SELECT id, table_id FROM table_rows WHERE id = ?',
[agentRowId]
);
if (!row) {
const err = new Error(`No agent row with id=${agentRowId}`);
err.code = 'AGENT_NOT_FOUND';
throw err;
}
if (Number(row.table_id) !== AGENTS_TABLE_ID) {
const err = new Error(
`Row ${agentRowId} is not an agent (table_id=${row.table_id}, expected ${AGENTS_TABLE_ID})`
);
err.code = 'NOT_AN_AGENT';
throw err;
}
return row;
}
/**
* Mint a new agent revision.
*
* Inserts one `agent_versions` row with the next monotonic version_int for the
* agent. The genesis revision (first row for the agent) is activated by default
* — an agent with only draft revisions is not dispatchable — so a single mint
* call yields exactly one active row (ADR-0007-S acceptance #1). Pass
* `activate: false` to mint a draft, or `activate: true` to atomically promote
* a non-genesis revision on mint.
*
* @param {Object} input
* @param {number} input.agentRowId - AI-Agents row id (table 1784). Required.
* @param {string} [input.agentSlug] - human-readable lineage key.
* @param {string} [input.prompt] - system prompt for this revision.
* @param {Object} [input.config] - model/tool/skill config (JSONB).
* @param {number} [input.rubricScore] - Examine-phase score.
* @param {number} [input.trafficPct] - A/B traffic share 0..100.
* @param {number} [input.parentVersion] - lineage; defaults to current max version.
* @param {number} [input.createdBy] - minter user id.
* @param {boolean}[input.activate] - force activate (default: genesis-only).
* @returns {Promise<Object>} the inserted version row
*/
export async function mintAgentVersion(input = {}) {
const {
agentRowId,
agentSlug = null,
prompt = null,
config = {},
rubricScore = null,
trafficPct = 0,
parentVersion,
createdBy = null,
activate,
} = input;
if (agentRowId == null) {
const err = new Error('mintAgentVersion: agentRowId is required');
err.code = 'VALIDATION';
throw err;
}
return withTransactionAsync(async (trx) => {
await assertAgentRow(agentRowId, trx);
const agg = await trx.get(
`SELECT COUNT(*)::int AS cnt, COALESCE(MAX(version_int), 0)::int AS maxv
FROM agent_versions WHERE agent_row_id = ?`,
[agentRowId]
);
const isGenesis = agg.cnt === 0;
const nextVersion = agg.maxv + 1;
const effectiveParent = parentVersion !== undefined ? parentVersion : (isGenesis ? null : agg.maxv);
// Genesis is live by default; otherwise draft unless caller forces activate.
const shouldActivate = activate === undefined ? isGenesis : !!activate;
const status = shouldActivate ? 'active' : 'draft';
// Retire any current active row first so the partial unique index never
// sees two 'active' rows (matters only when activating a non-genesis mint).
if (shouldActivate) {
await trx.run(
`UPDATE agent_versions SET status = 'retired', updated_at = NOW()
WHERE agent_row_id = ? AND status = 'active'`,
[agentRowId]
);
}
const inserted = await trx.get(
`INSERT INTO agent_versions
(agent_row_id, agent_slug, version_int, parent_version, status,
prompt_blob, config_json, rubric_score, traffic_pct, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?, ?)
RETURNING *`,
[
agentRowId,
agentSlug,
nextVersion,
effectiveParent,
status,
prompt,
JSON.stringify(config || {}),
rubricScore,
trafficPct,
createdBy,
]
);
return inserted;
});
}
/**
* Promote a revision to 'active'. Atomically retires whatever revision was
* active for the same agent (retire-before-activate, single transaction), so
* there is never more than one active row (ADR-0007-S acceptance #2).
* @param {number} versionId
* @returns {Promise<Object>} the now-active version row
*/
export async function promoteAgentVersion(versionId) {
return withTransactionAsync(async (trx) => {
const version = await trx.get('SELECT * FROM agent_versions WHERE id = ?', [versionId]);
if (!version) {
const err = new Error(`No agent_version with id=${versionId}`);
err.code = 'VERSION_NOT_FOUND';
throw err;
}
if (version.status === 'active') return version; // already active — no-op
// Retire the current active sibling(s) BEFORE activating this one.
await trx.run(
`UPDATE agent_versions SET status = 'retired', updated_at = NOW()
WHERE agent_row_id = ? AND status = 'active' AND id <> ?`,
[version.agent_row_id, versionId]
);
const promoted = await trx.get(
`UPDATE agent_versions SET status = 'active', updated_at = NOW()
WHERE id = ? RETURNING *`,
[versionId]
);
return promoted;
});
}
/**
* Retire a revision (idempotent). A retired active revision leaves the agent
* with no active version until another is promoted.
* @param {number} versionId
* @returns {Promise<Object|null>} the retired row, or null if not found
*/
export async function retireAgentVersion(versionId) {
const row = await dbGet(
`UPDATE agent_versions SET status = 'retired', updated_at = NOW()
WHERE id = ? RETURNING *`,
[versionId]
);
return row || null;
}
/** Fetch a single revision by id. */
export async function getAgentVersion(versionId) {
return (await dbGet('SELECT * FROM agent_versions WHERE id = ?', [versionId])) || null;
}
/** List all revisions for an agent, newest version first. */
export async function listAgentVersions(agentRowId) {
return dbAll(
'SELECT * FROM agent_versions WHERE agent_row_id = ? ORDER BY version_int DESC',
[agentRowId]
);
}
/** The current active revision for an agent, or null. */
export async function getActiveVersion(agentRowId) {
return (
(await dbGet(
`SELECT * FROM agent_versions WHERE agent_row_id = ? AND status = 'active'`,
[agentRowId]
)) || null
);
}
export default {
AGENTS_TABLE_ID,
VERSION_STATUSES,
assertAgentRow,
mintAgentVersion,
promoteAgentVersion,
retireAgentVersion,
getAgentVersion,
listAgentVersions,
getActiveVersion,
};