godcrm/backend/services/agentTableResolver.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

139 lines
6.4 KiB
JavaScript

/**
* Agent-table resolver — ADR-164 Phase 3a (companion to the handle-first identity work).
*
* Single, telemetry-instrumented choke point answering ONE question:
* "which universal_tables rows are agent-config tables?"
*
* It replaces 11 scattered `ut.name = 'AI Agents'` SQL predicates (class A — the sites
* OUTSIDE the identity resolve path; the class-B resolver predicates in agent-users.js /
* chatAgentHelpers.js stay put, gated on the deprecated_slug_resolve telemetry, Phase 4).
*
* WHY: matching agent tables by display name is a convention, not a contract. A renamed,
* copy_space-cloned, or passport-less sub-agent table is a real agent table the name match
* misses. The stable marker is `universal_tables.table_type = 'ai_agents'` (backfilled by
* migration 077, stamped on every creation / copy path).
*
* Three modes via env AGENT_TABLE_RESOLVE_MODE (default 'dual'):
* - 'name' : legacy — match by display name only. Instant kill-switch / rollback,
* no redeploy: an out-of-band flip returns behaviour to the pre-Phase-3a state.
* - 'marker' : match by table_type only. Phase 4 target, flipped once the safety gate below
* is provably empty in production telemetry.
* - 'dual' : DEFAULT. Compute BOTH sets, RETURN the name-set (so live behaviour on every
* hot path is byte-identical to the old literal — a regression is structurally
* impossible), and log divergence, separately:
* name \ marker -> WARN event=agent_table_resolve_divergence kind=name_minus_marker
* SAFETY GATE: MUST be empty. A non-empty set means a table we
* actively serve has no marker (a backfill/stamp gap) — Phase 4
* must NOT flip to 'marker' until this is zero, exactly as
* Phase 1 gated retiring the slug fallback on deprecated_slug_resolve.
* marker \ name -> INFO event=agent_table_resolve_divergence kind=marker_minus_name
* FEATURE SIGNAL: renamed / copy_space / passport-less sub-agent
* tables the name convention never saw. Expected to be > 0.
*
* Returns: number[] of universal_tables.id (deduped integers). Empty array => the caller yields
* no agent tables, identical to the old `WHERE ut.name='AI Agents'` matching nothing.
*
* The returned ids are internal integer primary keys from our own DB — callers inline them into an
* IN(...) list (see coercion below); there is no user-controlled value in the list.
*/
import { dbAll, isPostgres } from '../database/connection.js';
import { apiLogger } from '../utils/logger.js';
const AGENT_TABLE_NAME = 'AI Agents';
const AGENT_TABLE_TYPE = 'ai_agents';
const VALID_MODES = new Set(['name', 'marker', 'dual']);
function resolveMode() {
const raw = (process.env.AGENT_TABLE_RESOLVE_MODE || 'dual').trim().toLowerCase();
return VALID_MODES.has(raw) ? raw : 'dual';
}
function toIntIds(rows) {
// Positive integer PKs only — drops null/0/negatives (Number(null) === 0 would otherwise slip through).
return [...new Set(rows.map(r => Number(r.id)).filter(n => Number.isInteger(n) && n > 0))];
}
async function selectByName(spaceId) {
const pg = isPostgres();
if (spaceId != null) {
return toIntIds(await dbAll(
pg
? `SELECT ut.id FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE ut.name = $1 AND p.space_id = $2`
: `SELECT ut.id FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE ut.name = ? AND p.space_id = ?`,
[AGENT_TABLE_NAME, spaceId],
));
}
return toIntIds(await dbAll(
pg
? `SELECT ut.id FROM universal_tables ut WHERE ut.name = $1`
: `SELECT ut.id FROM universal_tables ut WHERE ut.name = ?`,
[AGENT_TABLE_NAME],
));
}
async function selectByMarker(spaceId) {
const pg = isPostgres();
if (spaceId != null) {
return toIntIds(await dbAll(
pg
? `SELECT ut.id FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE ut.table_type = $1 AND p.space_id = $2`
: `SELECT ut.id FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE ut.table_type = ? AND p.space_id = ?`,
[AGENT_TABLE_TYPE, spaceId],
));
}
return toIntIds(await dbAll(
pg
? `SELECT ut.id FROM universal_tables ut WHERE ut.table_type = $1`
: `SELECT ut.id FROM universal_tables ut WHERE ut.table_type = ?`,
[AGENT_TABLE_TYPE],
));
}
/**
* Resolve the set of agent-config table ids.
*
* @param {object} [opts]
* @param {number} [opts.spaceId] Restrict to tables whose project lives in this space.
* Omit for a global (all-spaces) resolve.
* @returns {Promise<number[]>} universal_tables.id list (may be empty).
*/
export async function getAgentTableIds({ spaceId = null } = {}) {
const mode = resolveMode();
if (mode === 'name') return selectByName(spaceId);
if (mode === 'marker') return selectByMarker(spaceId);
// dual (default): shadow-read. Live answer = name-set; marker-set only feeds telemetry.
const [nameIds, markerIds] = await Promise.all([selectByName(spaceId), selectByMarker(spaceId)]);
const nameSet = new Set(nameIds);
const markerSet = new Set(markerIds);
const nameMinusMarker = nameIds.filter(id => !markerSet.has(id));
if (nameMinusMarker.length > 0) {
apiLogger.warn(
{ event: 'agent_table_resolve_divergence', kind: 'name_minus_marker', spaceId, tableIds: nameMinusMarker },
'ADR-164 Phase 3a: agent table(s) matched by name but MISSING the ai_agents marker — backfill/stamp gap; safety gate must be empty before flipping to marker mode',
);
}
const markerMinusName = markerIds.filter(id => !nameSet.has(id));
if (markerMinusName.length > 0) {
apiLogger.info(
{ event: 'agent_table_resolve_divergence', kind: 'marker_minus_name', spaceId, tableIds: markerMinusName },
'ADR-164 Phase 3a: agent table(s) matched by marker but not by name (renamed / copy_space / passport-less sub-agent) — expected feature signal',
);
}
return nameIds;
}
/** Exposed for characterization tests — the constants the resolver keys on. */
export const AGENT_TABLE_MARKERS = Object.freeze({ NAME: AGENT_TABLE_NAME, TYPE: AGENT_TABLE_TYPE });