From 644b38f2f668427ac9ac3408606b48648d1f4b28 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 10 Apr 2026 21:18:47 +0900 Subject: [PATCH] refactor: remove read-time repair from canonical hydrate paths --- src/db.test.ts | 174 +++++++++++++++++ src/db/canonical-role-metadata.ts | 309 +++++++++++++++++++++++++++--- src/db/channel-owner-leases.ts | 9 +- src/db/paired-state.ts | 9 +- src/db/schema.ts | 12 +- src/db/service-handoffs.ts | 9 +- src/db/work-items.ts | 37 +++- 7 files changed, 513 insertions(+), 46 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index 3bc3b53..de5c27c 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -1311,6 +1311,57 @@ describe('paired task state', () => { }); }); + it('fails fast when a paired task row loses canonical agent metadata after init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-strict-read-'); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + const fileDb = new Database(dbPath); + initializeDatabaseSchema(fileDb); + fileDb.close(); + + _initTestDatabaseFromFile(dbPath); + createPairedTask({ + id: 'paired-task-strict-read', + chat_jid: 'dc:paired-task-strict-read', + group_folder: 'paired-task-strict-read', + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: null, + title: 'strict read task', + source_ref: null, + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-10T00:00:00.000Z', + updated_at: '2026-04-10T00:00:00.000Z', + }); + + const rawDb = new Database(dbPath); + rawDb + .prepare( + `UPDATE paired_tasks + SET reviewer_agent_type = NULL + WHERE id = ?`, + ) + .run('paired-task-strict-read'); + rawDb.close(); + + expect(() => getPairedTaskById('paired-task-strict-read')).toThrow( + /cannot read reviewer_agent_type from stored row metadata/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + it('preserves stored reviewer service ids during init even when reviewer agent metadata exists', () => { const tempDir = fs.mkdtempSync( '/tmp/ejclaw-channel-owner-stored-reviewer-', @@ -1371,6 +1422,44 @@ describe('paired task state', () => { }); }); + it('fails fast when a channel owner lease row loses canonical reviewer metadata after init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-channel-owner-strict-read-'); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + const fileDb = new Database(dbPath); + initializeDatabaseSchema(fileDb); + fileDb.close(); + + _initTestDatabaseFromFile(dbPath); + setChannelOwnerLease({ + chat_jid: 'dc:channel-owner-strict-read', + owner_service_id: CODEX_MAIN_SERVICE_ID, + reviewer_service_id: CLAUDE_SERVICE_ID, + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + reason: 'strict read setup', + }); + + const rawDb = new Database(dbPath); + rawDb + .prepare( + `UPDATE channel_owner + SET reviewer_agent_type = NULL + WHERE chat_jid = ?`, + ) + .run('dc:channel-owner-strict-read'); + rawDb.close(); + + expect(() => + getChannelOwnerLease('dc:channel-owner-strict-read'), + ).toThrow(/cannot read reviewer_agent_type from stored row metadata/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + it('fails init when stored paired task metadata conflicts with stored service ids even if room settings differ', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-paired-task-ssot-'); const dbPath = path.join(tempDir, 'messages.db'); @@ -3381,6 +3470,48 @@ describe('service handoff completion', () => { ); }); + it('fails fast when a service handoff row loses canonical target metadata after init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-handoff-strict-read-'); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + const fileDb = new Database(dbPath); + initializeDatabaseSchema(fileDb); + fileDb.close(); + + _initTestDatabaseFromFile(dbPath); + const handoff = createServiceHandoff({ + chat_jid: 'dc:handoff-strict-read', + group_folder: 'handoff-strict-read', + source_service_id: CLAUDE_SERVICE_ID, + target_service_id: CODEX_REVIEW_SERVICE_ID, + source_role: 'owner', + target_role: 'reviewer', + source_agent_type: 'claude-code', + target_agent_type: 'codex', + prompt: 'strict read handoff', + intended_role: 'reviewer', + }); + + const rawDb = new Database(dbPath); + rawDb + .prepare( + `UPDATE service_handoffs + SET target_agent_type = '' + WHERE id = ?`, + ) + .run(handoff.id); + rawDb.close(); + + expect(() => getPendingServiceHandoffs(CODEX_REVIEW_SERVICE_ID)).toThrow( + /cannot read target_agent_type from stored row metadata/, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + it('preserves an explicit reviewer target service id when creating a new handoff', () => { const handoff = createServiceHandoff({ chat_jid: 'dc:handoff-stored-reviewer', @@ -8686,6 +8817,49 @@ describe('work items', () => { ); }); + it('fails fast when a work item row loses canonical agent metadata after init', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-work-item-strict-read-'); + const dbPath = path.join(tempDir, 'messages.db'); + + try { + const fileDb = new Database(dbPath); + initializeDatabaseSchema(fileDb); + fileDb.close(); + + _initTestDatabaseFromFile(dbPath); + const item = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:work-item-strict-read', + agent_type: 'codex', + service_id: CODEX_REVIEW_SERVICE_ID, + delivery_role: 'reviewer', + start_seq: 1, + end_seq: 2, + result_payload: 'strict read work item', + }); + + const rawDb = new Database(dbPath); + rawDb + .prepare( + `UPDATE work_items + SET agent_type = '' + WHERE id = ?`, + ) + .run(item.id); + rawDb.close(); + + expect(() => + getOpenWorkItemForChat( + 'dc:work-item-strict-read', + CODEX_REVIEW_SERVICE_ID, + ), + ).toThrow(/cannot read agent_type from stored row metadata/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + _initTestDatabase(); + } + }); + it('backfills work item service ids during init on a canonical work_items schema without service_id columns', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-work-items-canonical-'); const dbPath = path.join(tempDir, 'messages.db'); diff --git a/src/db/canonical-role-metadata.ts b/src/db/canonical-role-metadata.ts index 14fd2ee..a0ad244 100644 --- a/src/db/canonical-role-metadata.ts +++ b/src/db/canonical-role-metadata.ts @@ -22,7 +22,9 @@ interface RequiredRoleMetadataInput { interface OptionalRoleMetadataInput extends RequiredRoleMetadataInput {} -function resolveRequiredAgentType(input: RequiredRoleMetadataInput): AgentType { +function resolveFilledRequiredAgentType( + input: RequiredRoleMetadataInput, +): AgentType { const persistedAgentType = normalizeStoredAgentType(input.storedAgentType); const inferredAgentType = inferAgentTypeFromServiceShadow( input.storedServiceId, @@ -48,7 +50,33 @@ function resolveRequiredAgentType(input: RequiredRoleMetadataInput): AgentType { ); } -function resolveRequiredServiceId( +function readStoredRequiredAgentType( + input: RequiredRoleMetadataInput, +): AgentType { + const persistedAgentType = normalizeStoredAgentType(input.storedAgentType); + const inferredAgentType = inferAgentTypeFromServiceShadow( + input.storedServiceId, + ); + if ( + persistedAgentType && + inferredAgentType && + persistedAgentType !== inferredAgentType + ) { + throw new Error( + `${input.context}: ${input.role}_agent_type conflicts with ${input.role}_service_id`, + ); + } + + if (persistedAgentType) { + return persistedAgentType; + } + + throw new Error( + `${input.context}: cannot read ${input.role}_agent_type from stored row metadata`, + ); +} + +function resolveFilledRequiredServiceId( context: string, role: PairedRoomRole, agentType: AgentType, @@ -65,7 +93,21 @@ function resolveRequiredServiceId( ); } -function resolveOptionalRoleMetadata(input: OptionalRoleMetadataInput): { +function readStoredRequiredServiceId( + context: string, + role: PairedRoomRole, + storedServiceId?: string | null, +): string { + if (storedServiceId) { + return storedServiceId; + } + + throw new Error( + `${context}: cannot read ${role}_service_id from stored row metadata`, + ); +} + +function resolveFilledOptionalRoleMetadata(input: OptionalRoleMetadataInput): { agentType: AgentType | null; serviceId: string | null; } { @@ -73,10 +115,10 @@ function resolveOptionalRoleMetadata(input: OptionalRoleMetadataInput): { return { agentType: null, serviceId: null }; } - const agentType = resolveRequiredAgentType(input); + const agentType = resolveFilledRequiredAgentType(input); return { agentType, - serviceId: resolveRequiredServiceId( + serviceId: resolveFilledRequiredServiceId( input.context, input.role, agentType, @@ -85,6 +127,25 @@ function resolveOptionalRoleMetadata(input: OptionalRoleMetadataInput): { }; } +function readStoredOptionalRoleMetadata(input: OptionalRoleMetadataInput): { + agentType: AgentType | null; + serviceId: string | null; +} { + if (!input.storedAgentType && !input.storedServiceId) { + return { agentType: null, serviceId: null }; + } + + const agentType = readStoredRequiredAgentType(input); + return { + agentType, + serviceId: readStoredRequiredServiceId( + input.context, + input.role, + input.storedServiceId, + ), + }; +} + export interface CanonicalPairedTaskMetadata { ownerAgentType: AgentType; reviewerAgentType: AgentType; @@ -93,7 +154,7 @@ export interface CanonicalPairedTaskMetadata { reviewerServiceId: string; } -export function canonicalizePairedTaskMetadata(input: { +export function fillCanonicalPairedTaskMetadata(input: { id: string; owner_service_id?: string | null; reviewer_service_id?: string | null; @@ -102,14 +163,14 @@ export function canonicalizePairedTaskMetadata(input: { arbiter_agent_type?: string | null; }): CanonicalPairedTaskMetadata { const context = `paired_tasks(${input.id})`; - const ownerAgentType = resolveRequiredAgentType({ + const ownerAgentType = resolveFilledRequiredAgentType({ context, role: 'owner', storedAgentType: input.owner_agent_type, storedServiceId: input.owner_service_id, fallbackAgentType: OWNER_AGENT_TYPE, }); - const reviewerAgentType = resolveRequiredAgentType({ + const reviewerAgentType = resolveFilledRequiredAgentType({ context, role: 'reviewer', storedAgentType: input.reviewer_agent_type, @@ -124,13 +185,13 @@ export function canonicalizePairedTaskMetadata(input: { normalizeStoredAgentType(input.arbiter_agent_type) ?? ARBITER_AGENT_TYPE ?? null, - ownerServiceId: resolveRequiredServiceId( + ownerServiceId: resolveFilledRequiredServiceId( context, 'owner', ownerAgentType, input.owner_service_id, ), - reviewerServiceId: resolveRequiredServiceId( + reviewerServiceId: resolveFilledRequiredServiceId( context, 'reviewer', reviewerAgentType, @@ -139,6 +200,46 @@ export function canonicalizePairedTaskMetadata(input: { }; } +export function readCanonicalPairedTaskMetadata(input: { + id: string; + owner_service_id?: string | null; + reviewer_service_id?: string | null; + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; +}): CanonicalPairedTaskMetadata { + const context = `paired_tasks(${input.id})`; + const ownerAgentType = readStoredRequiredAgentType({ + context, + role: 'owner', + storedAgentType: input.owner_agent_type, + storedServiceId: input.owner_service_id, + }); + const reviewerAgentType = readStoredRequiredAgentType({ + context, + role: 'reviewer', + storedAgentType: input.reviewer_agent_type, + storedServiceId: input.reviewer_service_id, + }); + + return { + ownerAgentType, + reviewerAgentType, + arbiterAgentType: + normalizeStoredAgentType(input.arbiter_agent_type) ?? null, + ownerServiceId: readStoredRequiredServiceId( + context, + 'owner', + input.owner_service_id, + ), + reviewerServiceId: readStoredRequiredServiceId( + context, + 'reviewer', + input.reviewer_service_id, + ), + }; +} + export interface CanonicalChannelOwnerLeaseMetadata { ownerAgentType: AgentType; reviewerAgentType: AgentType | null; @@ -148,7 +249,7 @@ export interface CanonicalChannelOwnerLeaseMetadata { arbiterServiceId: string | null; } -export function canonicalizeChannelOwnerLeaseMetadata(input: { +export function fillCanonicalChannelOwnerLeaseMetadata(input: { chat_jid: string; owner_service_id?: string | null; reviewer_service_id?: string | null; @@ -158,19 +259,19 @@ export function canonicalizeChannelOwnerLeaseMetadata(input: { arbiter_agent_type?: string | null; }): CanonicalChannelOwnerLeaseMetadata { const context = `channel_owner(${input.chat_jid})`; - const ownerAgentType = resolveRequiredAgentType({ + const ownerAgentType = resolveFilledRequiredAgentType({ context, role: 'owner', storedAgentType: input.owner_agent_type, storedServiceId: input.owner_service_id, }); - const reviewer = resolveOptionalRoleMetadata({ + const reviewer = resolveFilledOptionalRoleMetadata({ context, role: 'reviewer', storedAgentType: input.reviewer_agent_type, storedServiceId: input.reviewer_service_id, }); - const arbiter = resolveOptionalRoleMetadata({ + const arbiter = resolveFilledOptionalRoleMetadata({ context, role: 'arbiter', storedAgentType: input.arbiter_agent_type, @@ -181,7 +282,7 @@ export function canonicalizeChannelOwnerLeaseMetadata(input: { ownerAgentType, reviewerAgentType: reviewer.agentType, arbiterAgentType: arbiter.agentType, - ownerServiceId: resolveRequiredServiceId( + ownerServiceId: resolveFilledRequiredServiceId( context, 'owner', ownerAgentType, @@ -192,6 +293,49 @@ export function canonicalizeChannelOwnerLeaseMetadata(input: { }; } +export function readCanonicalChannelOwnerLeaseMetadata(input: { + chat_jid: string; + owner_service_id?: string | null; + reviewer_service_id?: string | null; + arbiter_service_id?: string | null; + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; +}): CanonicalChannelOwnerLeaseMetadata { + const context = `channel_owner(${input.chat_jid})`; + const ownerAgentType = readStoredRequiredAgentType({ + context, + role: 'owner', + storedAgentType: input.owner_agent_type, + storedServiceId: input.owner_service_id, + }); + const reviewer = readStoredOptionalRoleMetadata({ + context, + role: 'reviewer', + storedAgentType: input.reviewer_agent_type, + storedServiceId: input.reviewer_service_id, + }); + const arbiter = readStoredOptionalRoleMetadata({ + context, + role: 'arbiter', + storedAgentType: input.arbiter_agent_type, + storedServiceId: input.arbiter_service_id, + }); + + return { + ownerAgentType, + reviewerAgentType: reviewer.agentType, + arbiterAgentType: arbiter.agentType, + ownerServiceId: readStoredRequiredServiceId( + context, + 'owner', + input.owner_service_id, + ), + reviewerServiceId: reviewer.serviceId, + arbiterServiceId: arbiter.serviceId, + }; +} + function normalizeHandoffRole( role: string | null | undefined, ): PairedRoomRole | null { @@ -232,7 +376,7 @@ export interface CanonicalServiceHandoffMetadata { targetServiceId: string; } -export function canonicalizeServiceHandoffMetadata(input: { +export function fillCanonicalServiceHandoffMetadata(input: { id: number | string; chat_jid: string; source_service_id?: string | null; @@ -265,8 +409,6 @@ export function canonicalizeServiceHandoffMetadata(input: { `${context}: source_agent_type conflicts with source_service_id`, ); } - const sourceAgentType = - storedSourceAgentType ?? inferredSourceAgentType ?? null; const storedTargetAgentType = normalizeStoredAgentType( input.target_agent_type, ); @@ -301,12 +443,133 @@ export function canonicalizeServiceHandoffMetadata(input: { ); } + const sourceAgentTypeForShadow = + storedSourceAgentType ?? inferredSourceAgentType ?? null; const sourceServiceId = input.source_service_id ?? - (sourceRole != null && sourceAgentType != null - ? resolveRoleServiceShadow(sourceRole, sourceAgentType) + (sourceRole != null && sourceAgentTypeForShadow != null + ? resolveRoleServiceShadow(sourceRole, sourceAgentTypeForShadow) : null) ?? SERVICE_SESSION_SCOPE; + const sourceAgentType = + storedSourceAgentType ?? + inferredSourceAgentType ?? + inferAgentTypeFromServiceShadow(sourceServiceId) ?? + null; + + assertStoredRoleMatchesServiceShadow({ + context, + storedRole: sourceRole, + agentType: sourceAgentType, + serviceId: sourceServiceId, + roleField: 'source_role', + serviceField: 'source_service_id', + }); + assertStoredRoleMatchesServiceShadow({ + context, + storedRole: targetRole, + agentType: targetAgentType, + serviceId: targetServiceId, + roleField: 'target_role', + serviceField: 'target_service_id', + }); + + return { + sourceRole, + targetRole, + sourceAgentType, + targetAgentType, + sourceServiceId, + targetServiceId, + }; +} + +function readStoredHandoffAgentType(args: { + context: string; + field: 'source_agent_type' | 'target_agent_type'; + serviceField: 'source_service_id' | 'target_service_id'; + storedAgentType?: string | null; + storedServiceId?: string | null; +}): AgentType { + const persistedAgentType = normalizeStoredAgentType(args.storedAgentType); + const inferredAgentType = inferAgentTypeFromServiceShadow( + args.storedServiceId, + ); + if ( + persistedAgentType && + inferredAgentType && + persistedAgentType !== inferredAgentType + ) { + throw new Error( + `${args.context}: ${args.field} conflicts with ${args.serviceField}`, + ); + } + + if (persistedAgentType) { + return persistedAgentType; + } + + throw new Error( + `${args.context}: cannot read ${args.field} from stored row metadata`, + ); +} + +function readStoredHandoffServiceId(args: { + context: string; + field: 'source_service_id' | 'target_service_id'; + storedServiceId?: string | null; +}): string { + if (args.storedServiceId) { + return args.storedServiceId; + } + + throw new Error( + `${args.context}: cannot read ${args.field} from stored row metadata`, + ); +} + +export function readCanonicalServiceHandoffMetadata(input: { + id: number | string; + chat_jid: string; + source_service_id?: string | null; + target_service_id?: string | null; + source_role?: string | null; + target_role?: string | null; + intended_role?: string | null; + source_agent_type?: string | null; + target_agent_type?: string | null; +}): CanonicalServiceHandoffMetadata { + const context = `service_handoffs(${input.id})`; + const sourceRole = + normalizeHandoffRole(input.source_role) ?? + normalizeHandoffRole(input.intended_role); + const targetRole = + normalizeHandoffRole(input.target_role) ?? + normalizeHandoffRole(input.intended_role); + const sourceServiceId = readStoredHandoffServiceId({ + context, + field: 'source_service_id', + storedServiceId: input.source_service_id, + }); + const targetServiceId = readStoredHandoffServiceId({ + context, + field: 'target_service_id', + storedServiceId: input.target_service_id, + }); + const sourceAgentType = readStoredHandoffAgentType({ + context, + field: 'source_agent_type', + serviceField: 'source_service_id', + storedAgentType: input.source_agent_type, + storedServiceId: sourceServiceId, + }); + const targetAgentType = readStoredHandoffAgentType({ + context, + field: 'target_agent_type', + serviceField: 'target_service_id', + storedAgentType: input.target_agent_type, + storedServiceId: targetServiceId, + }); assertStoredRoleMatchesServiceShadow({ context, @@ -351,7 +614,7 @@ export function inferExecutionLeaseServiceIdFromCanonicalMetadata( ): string | null { switch (row.role) { case 'owner': { - const owner = resolveOptionalRoleMetadata({ + const owner = resolveFilledOptionalRoleMetadata({ context: `paired_task_execution_leases(${row.rowid})`, role: 'owner', storedAgentType: row.owner_agent_type, @@ -360,7 +623,7 @@ export function inferExecutionLeaseServiceIdFromCanonicalMetadata( return owner.serviceId; } case 'reviewer': { - const reviewer = resolveOptionalRoleMetadata({ + const reviewer = resolveFilledOptionalRoleMetadata({ context: `paired_task_execution_leases(${row.rowid})`, role: 'reviewer', storedAgentType: row.reviewer_agent_type, @@ -369,7 +632,7 @@ export function inferExecutionLeaseServiceIdFromCanonicalMetadata( return reviewer.serviceId; } case 'arbiter': { - const arbiter = resolveOptionalRoleMetadata({ + const arbiter = resolveFilledOptionalRoleMetadata({ context: `paired_task_execution_leases(${row.rowid})`, role: 'arbiter', storedAgentType: row.arbiter_agent_type, diff --git a/src/db/channel-owner-leases.ts b/src/db/channel-owner-leases.ts index 0991fd5..8d3ebc1 100644 --- a/src/db/channel-owner-leases.ts +++ b/src/db/channel-owner-leases.ts @@ -1,7 +1,10 @@ import { Database } from 'bun:sqlite'; import { AgentType } from '../types.js'; -import { canonicalizeChannelOwnerLeaseMetadata } from './canonical-role-metadata.js'; +import { + fillCanonicalChannelOwnerLeaseMetadata, + readCanonicalChannelOwnerLeaseMetadata, +} from './canonical-role-metadata.js'; export interface ChannelOwnerLeaseRow { chat_jid: string; @@ -49,7 +52,7 @@ function hydrateChannelOwnerLeaseRow( ownerServiceId, reviewerServiceId, arbiterServiceId, - } = canonicalizeChannelOwnerLeaseMetadata({ + } = readCanonicalChannelOwnerLeaseMetadata({ chat_jid: row.chat_jid, owner_service_id: row.owner_service_id, reviewer_service_id: row.reviewer_service_id, @@ -102,7 +105,7 @@ export function setChannelOwnerLeaseInDatabase( ownerServiceId, reviewerServiceId, arbiterServiceId, - } = canonicalizeChannelOwnerLeaseMetadata({ + } = fillCanonicalChannelOwnerLeaseMetadata({ chat_jid: input.chat_jid, owner_service_id: input.owner_service_id, reviewer_service_id: input.reviewer_service_id, diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index 2f2584b..0a08504 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -9,7 +9,10 @@ import { buildPairedTurnIdentity, resolvePairedTurnRole, } from '../paired-turn-identity.js'; -import { canonicalizePairedTaskMetadata } from './canonical-role-metadata.js'; +import { + fillCanonicalPairedTaskMetadata, + readCanonicalPairedTaskMetadata, +} from './canonical-role-metadata.js'; import { ensurePairedTurnQueuedInDatabase, markPairedTurnRunningInDatabase, @@ -74,7 +77,7 @@ function hydratePairedTaskRow( arbiterAgentType, ownerServiceId, reviewerServiceId, - } = canonicalizePairedTaskMetadata({ + } = readCanonicalPairedTaskMetadata({ id: row.id, owner_service_id: row.owner_service_id, reviewer_service_id: row.reviewer_service_id, @@ -142,7 +145,7 @@ export function createPairedTaskInDatabase( arbiterAgentType, ownerServiceId, reviewerServiceId, - } = canonicalizePairedTaskMetadata({ + } = fillCanonicalPairedTaskMetadata({ id: task.id, owner_service_id: task.owner_service_id, reviewer_service_id: task.reviewer_service_id, diff --git a/src/db/schema.ts b/src/db/schema.ts index de17b7c..fbbc203 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -6,9 +6,9 @@ import { resolveRoleServiceShadow, } from '../role-service-shadow.js'; import { - canonicalizeChannelOwnerLeaseMetadata, - canonicalizePairedTaskMetadata, - canonicalizeServiceHandoffMetadata, + fillCanonicalChannelOwnerLeaseMetadata, + fillCanonicalPairedTaskMetadata, + fillCanonicalServiceHandoffMetadata, inferExecutionLeaseServiceIdFromCanonicalMetadata, } from './canonical-role-metadata.js'; import { @@ -2320,7 +2320,7 @@ function backfillCanonicalPairedTaskServiceIds(database: Database): void { arbiterAgentType, ownerServiceId, reviewerServiceId, - } = canonicalizePairedTaskMetadata({ + } = fillCanonicalPairedTaskMetadata({ id: row.id, owner_service_id: row.owner_service_id, reviewer_service_id: row.reviewer_service_id, @@ -2400,7 +2400,7 @@ function backfillCanonicalChannelOwnerServiceIds(database: Database): void { ownerServiceId, reviewerServiceId, arbiterServiceId, - } = canonicalizeChannelOwnerLeaseMetadata({ + } = fillCanonicalChannelOwnerLeaseMetadata({ chat_jid: row.chat_jid, owner_service_id: row.owner_service_id, reviewer_service_id: row.reviewer_service_id, @@ -2486,7 +2486,7 @@ function backfillCanonicalServiceHandoffServiceIds(database: Database): void { targetAgentType, sourceServiceId, targetServiceId, - } = canonicalizeServiceHandoffMetadata({ + } = fillCanonicalServiceHandoffMetadata({ id: row.id, chat_jid: row.chat_jid, source_service_id: row.source_service_id, diff --git a/src/db/service-handoffs.ts b/src/db/service-handoffs.ts index 8cfaa5e..3c4df03 100644 --- a/src/db/service-handoffs.ts +++ b/src/db/service-handoffs.ts @@ -19,7 +19,10 @@ import { getLatestMessageSeqAtOrBeforeFromDatabase, normalizeSeqCursor, } from './messages.js'; -import { canonicalizeServiceHandoffMetadata } from './canonical-role-metadata.js'; +import { + fillCanonicalServiceHandoffMetadata, + readCanonicalServiceHandoffMetadata, +} from './canonical-role-metadata.js'; import { getRouterStateFromDatabase, setRouterStateInDatabase, @@ -133,7 +136,7 @@ function hydrateServiceHandoffRow( targetAgentType, sourceServiceId, targetServiceId, - } = canonicalizeServiceHandoffMetadata({ + } = readCanonicalServiceHandoffMetadata({ id: row.id, chat_jid: row.chat_jid, source_service_id: row.source_service_id, @@ -249,7 +252,7 @@ export function createServiceHandoffInDatabase( targetAgentType, sourceServiceId, targetServiceId, - } = canonicalizeServiceHandoffMetadata({ + } = fillCanonicalServiceHandoffMetadata({ id: 'new', chat_jid: input.chat_jid, source_service_id: input.source_service_id, diff --git a/src/db/work-items.ts b/src/db/work-items.ts index be58c5e..f5af722 100644 --- a/src/db/work-items.ts +++ b/src/db/work-items.ts @@ -53,7 +53,7 @@ function normalizeStoredAgentType( return undefined; } -function resolveStoredWorkItemServiceId(args: { +function fillCanonicalWorkItemServiceId(args: { agentType: AgentType; deliveryRole?: PairedRoomRole | null; serviceId?: string | null; @@ -65,16 +65,37 @@ function resolveStoredWorkItemServiceId(args: { ); } +function readStoredWorkItemAgentType( + row: Pick, +): AgentType { + const agentType = normalizeStoredAgentType(row.agent_type); + if (agentType) { + return agentType; + } + + throw new Error( + `work_items(${row.id}): cannot read agent_type from stored row metadata`, + ); +} + +function readStoredWorkItemServiceId( + row: Pick, +): string { + if (row.service_id) { + return normalizeServiceId(row.service_id); + } + + throw new Error( + `work_items(${row.id}): cannot read service_id from stored row metadata`, + ); +} + function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem { - const agentType = normalizeStoredAgentType(row.agent_type) ?? 'claude-code'; + const agentType = readStoredWorkItemAgentType(row); return { ...row, agent_type: agentType, - service_id: resolveStoredWorkItemServiceId({ - agentType, - deliveryRole: row.delivery_role, - serviceId: row.service_id, - }), + service_id: readStoredWorkItemServiceId(row), }; } @@ -182,7 +203,7 @@ export function createProducedWorkItemInDatabase( ): WorkItem { const now = new Date().toISOString(); const agentType = input.agent_type || 'claude-code'; - const serviceId = resolveStoredWorkItemServiceId({ + const serviceId = fillCanonicalWorkItemServiceId({ agentType, deliveryRole: input.delivery_role, serviceId: input.service_id,