1294 lines
35 KiB
TypeScript
1294 lines
35 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
import fs from 'fs';
|
|
|
|
import {
|
|
ASSISTANT_NAME,
|
|
ARBITER_AGENT_TYPE,
|
|
CLAUDE_SERVICE_ID,
|
|
CODEX_MAIN_SERVICE_ID,
|
|
CODEX_REVIEW_SERVICE_ID,
|
|
normalizeServiceId,
|
|
OWNER_AGENT_TYPE,
|
|
REVIEWER_AGENT_TYPE,
|
|
SERVICE_ID,
|
|
SERVICE_SESSION_SCOPE,
|
|
} from './config.js';
|
|
import {
|
|
isValidGroupFolder,
|
|
resolveTaskRuntimeIpcPath as resolveTaskRuntimeIpcPathFromGroup,
|
|
resolveServiceTaskSessionsPath as resolveServiceTaskSessionsPathFromGroup,
|
|
resolveTaskSessionsPath as resolveTaskSessionsPathFromGroup,
|
|
} from './group-folder.js';
|
|
import { logger } from './logger.js';
|
|
import {
|
|
type RoomModeSource,
|
|
type RoomRegistrationSnapshot,
|
|
type StoredRoomSettings,
|
|
buildRegisteredGroupFromStoredSettings,
|
|
collectRegisteredAgentTypes,
|
|
collectRegisteredAgentTypesForFolder,
|
|
collectRoomRegistrationSnapshot,
|
|
getLegacyRegisteredGroup,
|
|
getLegacyRegisteredGroupRows,
|
|
getStoredRoomRowsFromDatabase,
|
|
getStoredRoomSettingsRowFromDatabase,
|
|
inferOwnerAgentTypeFromRegisteredAgentTypes,
|
|
inferRoomModeFromRegisteredAgentTypes,
|
|
insertStoredRoomSettings,
|
|
materializeRegisteredGroupsForRoom,
|
|
normalizeRoomModeSource,
|
|
normalizeStoredAgentType,
|
|
parseRegisteredGroupRow,
|
|
resolveStoredRoomCapabilityTypes,
|
|
resolveAssignedRoomFolder,
|
|
updateStoredRoomMetadata,
|
|
} from './db/room-registration.js';
|
|
import {
|
|
initializeDatabaseSchema,
|
|
migrateJsonStateFromFiles,
|
|
openDatabaseFromFile,
|
|
openInMemoryDatabase,
|
|
openPersistentDatabase,
|
|
} from './db/bootstrap.js';
|
|
import {
|
|
clearChannelOwnerLeaseInDatabase,
|
|
getAllChannelOwnerLeasesFromDatabase,
|
|
getChannelOwnerLeaseFromDatabase,
|
|
setChannelOwnerLeaseInDatabase,
|
|
type ChannelOwnerLeaseRow,
|
|
type SetChannelOwnerLeaseInput,
|
|
} from './db/channel-owner-leases.js';
|
|
import {
|
|
type MemoryRecord,
|
|
type MemoryScopeKind,
|
|
type MemorySourceKind,
|
|
type RecallMemoryQuery,
|
|
archiveMemoryInDatabase,
|
|
enforceMemoryBoundsInDatabase,
|
|
expireStaleMemoriesInDatabase,
|
|
recallMemoriesFromDatabase,
|
|
rememberMemoryInDatabase,
|
|
touchMemoriesInDatabase,
|
|
} from './db/memories.js';
|
|
import {
|
|
type ChatInfo,
|
|
getAllChatsFromDatabase,
|
|
getLastHumanMessageContentFromDatabase,
|
|
getLastHumanMessageSenderFromDatabase,
|
|
getLastHumanMessageTimestampFromDatabase,
|
|
getLatestMessageSeqAtOrBeforeFromDatabase,
|
|
getMessagesSinceFromDatabase,
|
|
getMessagesSinceSeqFromDatabase,
|
|
getNewMessagesBySeqFromDatabase,
|
|
getNewMessagesFromDatabase,
|
|
getRecentChatMessagesFromDatabase,
|
|
hasRecentRestartAnnouncementInDatabase,
|
|
normalizeSeqCursor,
|
|
storeChatMetadataInDatabase,
|
|
storeMessageInDatabase,
|
|
} from './db/messages.js';
|
|
import {
|
|
type CreateProducedWorkItemInput,
|
|
type WorkItem,
|
|
createProducedWorkItemInDatabase,
|
|
getOpenWorkItemForChatFromDatabase,
|
|
getOpenWorkItemFromDatabase,
|
|
markWorkItemDeliveredInDatabase,
|
|
markWorkItemDeliveryRetryInDatabase,
|
|
} from './db/work-items.js';
|
|
import {
|
|
type PairedTaskUpdates,
|
|
createPairedTaskInDatabase,
|
|
getLastBotFinalMessageFromDatabase,
|
|
getLatestOpenPairedTaskForChatFromDatabase,
|
|
getLatestPairedTaskForChatFromDatabase,
|
|
getPairedProjectFromDatabase,
|
|
getPairedTaskByIdFromDatabase,
|
|
getPairedWorkspaceFromDatabase,
|
|
listPairedWorkspacesForTaskFromDatabase,
|
|
updatePairedTaskInDatabase,
|
|
upsertPairedProjectInDatabase,
|
|
upsertPairedWorkspaceInDatabase,
|
|
} from './db/paired-state.js';
|
|
import {
|
|
getLatestTurnNumberFromDatabase,
|
|
getPairedTurnOutputsFromDatabase,
|
|
insertPairedTurnOutputInDatabase,
|
|
} from './db/paired-turn-outputs.js';
|
|
import {
|
|
type CompleteServiceHandoffCursorInput,
|
|
type CreateServiceHandoffInput,
|
|
type ServiceHandoff,
|
|
claimServiceHandoffInDatabase,
|
|
completeServiceHandoffAndAdvanceTargetCursorInDatabase,
|
|
completeServiceHandoffInDatabase,
|
|
createServiceHandoffInDatabase,
|
|
failServiceHandoffInDatabase,
|
|
getAllPendingServiceHandoffsFromDatabase,
|
|
getPendingServiceHandoffsFromDatabase,
|
|
} from './db/service-handoffs.js';
|
|
import {
|
|
getLastRespondingAgentTypeFromDatabase,
|
|
getRouterStateForServiceFromDatabase,
|
|
getRouterStateFromDatabase,
|
|
setRouterStateForServiceInDatabase,
|
|
setRouterStateInDatabase,
|
|
} from './db/router-state.js';
|
|
import {
|
|
backfillChannelOwnerRoleMetadata,
|
|
backfillPairedTaskRoleMetadata,
|
|
backfillServiceHandoffServiceShadows,
|
|
backfillStoredRoomSettings,
|
|
backfillWorkItemServiceShadows,
|
|
rebuildChannelOwnerCanonicalSchema,
|
|
rebuildPairedTasksCanonicalSchema,
|
|
rebuildServiceHandoffsCanonicalSchema,
|
|
rebuildWorkItemsCanonicalSchema,
|
|
resolveStablePairedTaskOwnerAgentType,
|
|
resolveStableRoomRoleAgentType,
|
|
} from './db/legacy-rebuilds.js';
|
|
import {
|
|
deleteAllSessionsForGroupFromDatabase,
|
|
deleteSessionFromDatabase,
|
|
getAllSessionsForAgentTypeFromDatabase,
|
|
getSessionFromDatabase,
|
|
setSessionInDatabase,
|
|
} from './db/sessions.js';
|
|
import { type SchemaMigrationHooks, tableHasColumn } from './db/schema.js';
|
|
import {
|
|
type CreateScheduledTaskInput,
|
|
type ScheduledTaskStatusTrackingUpdates,
|
|
type ScheduledTaskUpdates,
|
|
createTaskInDatabase,
|
|
findDuplicateCiWatcherInDatabase,
|
|
getAllTasksFromDatabase,
|
|
getDueTasksFromDatabase,
|
|
getRecentConsecutiveErrorsFromDatabase,
|
|
getTaskByIdFromDatabase,
|
|
getTasksForGroupFromDatabase,
|
|
hasActiveCiWatcherForChatInDatabase,
|
|
logTaskRunInDatabase,
|
|
updateTaskAfterRunInDatabase,
|
|
updateTaskInDatabase,
|
|
updateTaskStatusTrackingInDatabase,
|
|
} from './db/tasks.js';
|
|
import {
|
|
inferAgentTypeFromServiceShadow,
|
|
resolveRoleServiceShadow,
|
|
} from './role-service-shadow.js';
|
|
import { getTaskRuntimeTaskId } from './task-watch-status.js';
|
|
import {
|
|
NewMessage,
|
|
AgentType,
|
|
PairedProject,
|
|
PairedRoomRole,
|
|
RoomMode,
|
|
PairedTask,
|
|
PairedTurnOutput,
|
|
PairedWorkspace,
|
|
RegisteredGroup,
|
|
ScheduledTask,
|
|
TaskRunLog,
|
|
} from './types.js';
|
|
|
|
export { inferRoomModeFromRegisteredAgentTypes };
|
|
|
|
let db: Database;
|
|
|
|
interface StoredRoomModeRow {
|
|
roomMode: RoomMode;
|
|
source: RoomModeSource;
|
|
}
|
|
|
|
export interface AssignRoomInput {
|
|
name: string;
|
|
roomMode?: RoomMode;
|
|
ownerAgentType?: AgentType;
|
|
folder?: string;
|
|
trigger?: string;
|
|
requiresTrigger?: boolean;
|
|
isMain?: boolean;
|
|
workDir?: string;
|
|
addedAt?: string;
|
|
ownerAgentConfig?: RegisteredGroup['agentConfig'];
|
|
}
|
|
export type {
|
|
MemoryRecord,
|
|
MemoryScopeKind,
|
|
MemorySourceKind,
|
|
RecallMemoryQuery,
|
|
} from './db/memories.js';
|
|
export type { ChatInfo } from './db/messages.js';
|
|
export type { WorkItem } from './db/work-items.js';
|
|
export type { ChannelOwnerLeaseRow } from './db/channel-owner-leases.js';
|
|
export type { ServiceHandoff } from './db/service-handoffs.js';
|
|
|
|
function backfillMessageSeq(database: Database): void {
|
|
const rows = database
|
|
.prepare(
|
|
`SELECT rowid, seq
|
|
FROM messages
|
|
ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`,
|
|
)
|
|
.all() as Array<{ rowid: number; seq: number | null }>;
|
|
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let nextSeq = 1;
|
|
const assignSeq = database.prepare(
|
|
'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL',
|
|
);
|
|
const tx = database.transaction(() => {
|
|
for (const row of rows) {
|
|
if (row.seq === null) {
|
|
assignSeq.run(nextSeq, row.rowid);
|
|
}
|
|
nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1);
|
|
}
|
|
});
|
|
tx();
|
|
|
|
const maxSeqRow = database
|
|
.prepare('SELECT MAX(seq) AS maxSeq FROM messages')
|
|
.get() as { maxSeq: number | null };
|
|
const maxSeq = maxSeqRow.maxSeq ?? 0;
|
|
if (maxSeq > 0) {
|
|
database
|
|
.prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)')
|
|
.run(maxSeq);
|
|
}
|
|
}
|
|
|
|
function getSchemaMigrationHooks(): SchemaMigrationHooks {
|
|
return {
|
|
backfillMessageSeq,
|
|
backfillStoredRoomSettings,
|
|
backfillChannelOwnerRoleMetadata,
|
|
backfillWorkItemServiceShadows,
|
|
backfillServiceHandoffServiceShadows,
|
|
backfillPairedTaskRoleMetadata,
|
|
rebuildWorkItemsCanonicalSchema,
|
|
rebuildChannelOwnerCanonicalSchema,
|
|
rebuildPairedTasksCanonicalSchema,
|
|
rebuildServiceHandoffsCanonicalSchema,
|
|
};
|
|
}
|
|
|
|
export function initDatabase(): void {
|
|
db = openPersistentDatabase();
|
|
initializeDatabaseSchema(db, getSchemaMigrationHooks());
|
|
migrateJsonStateFromFiles({
|
|
setRouterState,
|
|
setSession,
|
|
writeLegacyRegisteredGroupAndSyncRoomSettings,
|
|
});
|
|
}
|
|
|
|
/** @internal - for tests only. Creates a fresh in-memory database. */
|
|
export function _initTestDatabase(): void {
|
|
db = openInMemoryDatabase();
|
|
initializeDatabaseSchema(db, getSchemaMigrationHooks());
|
|
}
|
|
|
|
/** @internal - for tests only. Opens an existing database file and runs schema/migrations. */
|
|
export function _initTestDatabaseFromFile(dbPath: string): void {
|
|
db = openDatabaseFromFile(dbPath);
|
|
initializeDatabaseSchema(db, getSchemaMigrationHooks());
|
|
}
|
|
|
|
/** @internal - for tests only. */
|
|
export function _setStoredRoomOwnerAgentTypeForTests(
|
|
chatJid: string,
|
|
ownerAgentType: AgentType | null,
|
|
): void {
|
|
if (!db) {
|
|
throw new Error('Database not initialized');
|
|
}
|
|
db.prepare(
|
|
`UPDATE room_settings
|
|
SET owner_agent_type = ?,
|
|
updated_at = ?
|
|
WHERE chat_jid = ?`,
|
|
).run(ownerAgentType, new Date().toISOString(), chatJid);
|
|
}
|
|
|
|
/** @internal - for tests only. */
|
|
export function _deleteStoredRoomSettingsForTests(chatJid: string): void {
|
|
if (!db) {
|
|
throw new Error('Database not initialized');
|
|
}
|
|
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
|
|
}
|
|
|
|
/** @internal - for tests only. */
|
|
export function _setMemoryTimestampsForTests(
|
|
id: number,
|
|
args: {
|
|
createdAt?: string;
|
|
lastUsedAt?: string | null;
|
|
archivedAt?: string | null;
|
|
},
|
|
): void {
|
|
const existing = db
|
|
.prepare(
|
|
`SELECT created_at, last_used_at, archived_at
|
|
FROM memories
|
|
WHERE id = ?`,
|
|
)
|
|
.get(id) as
|
|
| {
|
|
created_at: string;
|
|
last_used_at: string | null;
|
|
archived_at: string | null;
|
|
}
|
|
| undefined;
|
|
if (!existing) throw new Error(`Memory ${id} not found`);
|
|
|
|
db.prepare(
|
|
`UPDATE memories
|
|
SET created_at = ?, last_used_at = ?, archived_at = ?
|
|
WHERE id = ?`,
|
|
).run(
|
|
args.createdAt ?? existing.created_at,
|
|
args.lastUsedAt === undefined ? existing.last_used_at : args.lastUsedAt,
|
|
args.archivedAt === undefined ? existing.archived_at : args.archivedAt,
|
|
id,
|
|
);
|
|
}
|
|
|
|
export function touchMemories(ids: number[]): void {
|
|
touchMemoriesInDatabase(db, ids);
|
|
}
|
|
|
|
export function archiveMemory(id: number): void {
|
|
archiveMemoryInDatabase(db, id);
|
|
}
|
|
|
|
export function expireStaleMemories(args?: {
|
|
scopeKind?: MemoryScopeKind;
|
|
scopeKey?: string;
|
|
now?: string;
|
|
}): number {
|
|
return expireStaleMemoriesInDatabase(db, args);
|
|
}
|
|
|
|
export function enforceMemoryBounds(
|
|
scopeKind: MemoryScopeKind,
|
|
scopeKey: string,
|
|
): void {
|
|
enforceMemoryBoundsInDatabase(db, scopeKind, scopeKey);
|
|
}
|
|
|
|
export function rememberMemory(input: {
|
|
scopeKind: MemoryScopeKind;
|
|
scopeKey: string;
|
|
content: string;
|
|
keywords?: string[];
|
|
memoryKind?: string | null;
|
|
sourceKind: MemorySourceKind;
|
|
sourceRef?: string | null;
|
|
}): number {
|
|
return rememberMemoryInDatabase(db, input);
|
|
}
|
|
|
|
export function recallMemories(query: RecallMemoryQuery): MemoryRecord[] {
|
|
return recallMemoriesFromDatabase(db, query);
|
|
}
|
|
|
|
export function storeChatMetadata(
|
|
chatJid: string,
|
|
timestamp: string,
|
|
name?: string,
|
|
channel?: string,
|
|
isGroup?: boolean,
|
|
): void {
|
|
storeChatMetadataInDatabase(db, chatJid, timestamp, name, channel, isGroup);
|
|
}
|
|
|
|
export function getAllChats(): ChatInfo[] {
|
|
return getAllChatsFromDatabase(db);
|
|
}
|
|
|
|
export function storeMessage(msg: NewMessage): void {
|
|
storeMessageInDatabase(db, msg);
|
|
}
|
|
|
|
export function getNewMessages(
|
|
jids: string[],
|
|
lastTimestamp: string,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): { messages: NewMessage[]; newTimestamp: string } {
|
|
return getNewMessagesFromDatabase(db, jids, lastTimestamp, botPrefix, limit);
|
|
}
|
|
|
|
export function getMessagesSince(
|
|
chatJid: string,
|
|
sinceTimestamp: string,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): NewMessage[] {
|
|
return getMessagesSinceFromDatabase(
|
|
db,
|
|
chatJid,
|
|
sinceTimestamp,
|
|
botPrefix,
|
|
limit,
|
|
);
|
|
}
|
|
|
|
export function getLatestMessageSeqAtOrBefore(
|
|
timestamp: string,
|
|
chatJid?: string,
|
|
): number {
|
|
return getLatestMessageSeqAtOrBeforeFromDatabase(db, timestamp, chatJid);
|
|
}
|
|
|
|
export function getNewMessagesBySeq(
|
|
jids: string[],
|
|
lastSeqCursor: string | number,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): { messages: NewMessage[]; newSeqCursor: string } {
|
|
return getNewMessagesBySeqFromDatabase(
|
|
db,
|
|
jids,
|
|
lastSeqCursor,
|
|
botPrefix,
|
|
limit,
|
|
);
|
|
}
|
|
|
|
export function getMessagesSinceSeq(
|
|
chatJid: string,
|
|
sinceSeqCursor: string | number,
|
|
botPrefix: string,
|
|
limit: number = 200,
|
|
): NewMessage[] {
|
|
return getMessagesSinceSeqFromDatabase(
|
|
db,
|
|
chatJid,
|
|
sinceSeqCursor,
|
|
botPrefix,
|
|
limit,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the N most recent messages for a chat, ordered chronologically.
|
|
* Includes both human and bot messages for full conversation context.
|
|
* Used for conversation context retrieval.
|
|
*/
|
|
export function getRecentChatMessages(
|
|
chatJid: string,
|
|
limit: number = 20,
|
|
): NewMessage[] {
|
|
return getRecentChatMessagesFromDatabase(db, chatJid, limit);
|
|
}
|
|
|
|
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
|
return getLastHumanMessageTimestampFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function getLastHumanMessageSender(chatJid: string): string | null {
|
|
return getLastHumanMessageSenderFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function getLastHumanMessageContent(chatJid: string): string | null {
|
|
return getLastHumanMessageContentFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function hasRecentRestartAnnouncement(
|
|
chatJid: string,
|
|
sinceTimestamp: string,
|
|
): boolean {
|
|
return hasRecentRestartAnnouncementInDatabase(db, chatJid, sinceTimestamp);
|
|
}
|
|
|
|
export function getOpenWorkItem(
|
|
chatJid: string,
|
|
agentType: AgentType = 'claude-code',
|
|
serviceId: string = SERVICE_SESSION_SCOPE,
|
|
): WorkItem | undefined {
|
|
return getOpenWorkItemFromDatabase(db, chatJid, agentType, serviceId);
|
|
}
|
|
|
|
export function getOpenWorkItemForChat(chatJid: string): WorkItem | undefined {
|
|
return getOpenWorkItemForChatFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function createProducedWorkItem(
|
|
input: CreateProducedWorkItemInput,
|
|
): WorkItem {
|
|
return createProducedWorkItemInDatabase(db, input);
|
|
}
|
|
|
|
export function markWorkItemDelivered(
|
|
id: number,
|
|
deliveryMessageId?: string | null,
|
|
): void {
|
|
markWorkItemDeliveredInDatabase(db, id, deliveryMessageId);
|
|
}
|
|
|
|
export function markWorkItemDeliveryRetry(id: number, error: string): void {
|
|
markWorkItemDeliveryRetryInDatabase(db, id, error);
|
|
}
|
|
|
|
export function createTask(task: CreateScheduledTaskInput): void {
|
|
createTaskInDatabase(db, task);
|
|
}
|
|
|
|
export function getTaskById(id: string): ScheduledTask | undefined {
|
|
return getTaskByIdFromDatabase(db, id);
|
|
}
|
|
|
|
/**
|
|
* Find an existing active/paused CI watcher for the same channel + provider + metadata.
|
|
* Used to prevent duplicate watchers when both agents register for the same CI run.
|
|
*/
|
|
export function findDuplicateCiWatcher(
|
|
chatJid: string,
|
|
ciProvider: string,
|
|
ciMetadata: string,
|
|
): ScheduledTask | undefined {
|
|
return findDuplicateCiWatcherInDatabase(db, chatJid, ciProvider, ciMetadata);
|
|
}
|
|
|
|
export function getTasksForGroup(
|
|
groupFolder: string,
|
|
agentType?: AgentType,
|
|
): ScheduledTask[] {
|
|
return getTasksForGroupFromDatabase(db, groupFolder, agentType);
|
|
}
|
|
|
|
export function getAllTasks(agentType?: AgentType): ScheduledTask[] {
|
|
return getAllTasksFromDatabase(db, agentType);
|
|
}
|
|
|
|
export function updateTask(id: string, updates: ScheduledTaskUpdates): void {
|
|
updateTaskInDatabase(db, id, updates);
|
|
}
|
|
|
|
export function updateTaskStatusTracking(
|
|
id: string,
|
|
updates: ScheduledTaskStatusTrackingUpdates,
|
|
): void {
|
|
updateTaskStatusTrackingInDatabase(db, id, updates);
|
|
}
|
|
|
|
export function deleteTask(id: string): void {
|
|
const task = getTaskById(id);
|
|
// Delete child records first (FK constraint)
|
|
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
|
|
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
|
|
|
if (!task) return;
|
|
|
|
const runtimeTaskId = getTaskRuntimeTaskId(task);
|
|
if (!runtimeTaskId) return;
|
|
|
|
const cleanupTargets = [];
|
|
try {
|
|
cleanupTargets.push(
|
|
resolveTaskRuntimeIpcPathFromGroup(task.group_folder, runtimeTaskId),
|
|
resolveTaskSessionsPathFromGroup(task.group_folder, runtimeTaskId),
|
|
resolveServiceTaskSessionsPathFromGroup(
|
|
task.group_folder,
|
|
CLAUDE_SERVICE_ID,
|
|
runtimeTaskId,
|
|
),
|
|
resolveServiceTaskSessionsPathFromGroup(
|
|
task.group_folder,
|
|
CODEX_MAIN_SERVICE_ID,
|
|
runtimeTaskId,
|
|
),
|
|
resolveServiceTaskSessionsPathFromGroup(
|
|
task.group_folder,
|
|
CODEX_REVIEW_SERVICE_ID,
|
|
runtimeTaskId,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ taskId: id, groupFolder: task.group_folder, err },
|
|
'Failed to resolve task-scoped cleanup paths',
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const cleanupPath of cleanupTargets) {
|
|
try {
|
|
fs.rmSync(cleanupPath, { recursive: true, force: true });
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ taskId: id, cleanupPath, err },
|
|
'Failed to remove task-scoped runtime artifacts',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function hasActiveCiWatcherForChat(chatJid: string): boolean {
|
|
return hasActiveCiWatcherForChatInDatabase(db, chatJid);
|
|
}
|
|
|
|
export function getDueTasks(): ScheduledTask[] {
|
|
return getDueTasksFromDatabase(db);
|
|
}
|
|
|
|
export function updateTaskAfterRun(
|
|
id: string,
|
|
nextRun: string | null,
|
|
lastResult: string,
|
|
): void {
|
|
updateTaskAfterRunInDatabase(db, id, nextRun, lastResult);
|
|
}
|
|
|
|
export function logTaskRun(log: TaskRunLog): void {
|
|
logTaskRunInDatabase(db, log);
|
|
}
|
|
|
|
export function getRecentConsecutiveErrors(
|
|
taskId: string,
|
|
limit: number = 5,
|
|
): string[] {
|
|
return getRecentConsecutiveErrorsFromDatabase(db, taskId, limit);
|
|
}
|
|
|
|
// --- Router state accessors ---
|
|
|
|
export function getRouterState(key: string): string | undefined {
|
|
return getRouterStateFromDatabase(db, key, SERVICE_ID);
|
|
}
|
|
|
|
export function getRouterStateForService(
|
|
key: string,
|
|
serviceId: string,
|
|
): string | undefined {
|
|
return getRouterStateForServiceFromDatabase(db, key, serviceId);
|
|
}
|
|
|
|
export function setRouterState(key: string, value: string): void {
|
|
setRouterStateInDatabase(db, key, value);
|
|
}
|
|
|
|
export function setRouterStateForService(
|
|
key: string,
|
|
value: string,
|
|
serviceId: string,
|
|
): void {
|
|
setRouterStateForServiceInDatabase(db, key, value, serviceId);
|
|
}
|
|
|
|
// --- Session accessors ---
|
|
|
|
export function getSession(
|
|
groupFolder: string,
|
|
agentType: AgentType = 'claude-code',
|
|
): string | undefined {
|
|
return getSessionFromDatabase(db, groupFolder, agentType);
|
|
}
|
|
|
|
export function setSession(
|
|
groupFolder: string,
|
|
sessionId: string,
|
|
agentType: AgentType = 'claude-code',
|
|
): void {
|
|
setSessionInDatabase(db, groupFolder, agentType, sessionId);
|
|
}
|
|
|
|
export function deleteSession(
|
|
groupFolder: string,
|
|
agentType: AgentType = 'claude-code',
|
|
): void {
|
|
deleteSessionFromDatabase(db, groupFolder, agentType);
|
|
}
|
|
|
|
export function deleteAllSessionsForGroup(groupFolder: string): void {
|
|
deleteAllSessionsForGroupFromDatabase(db, groupFolder);
|
|
}
|
|
|
|
export function getAllSessions(): Record<string, string> {
|
|
const currentAgentType =
|
|
inferAgentTypeFromServiceShadow(SERVICE_SESSION_SCOPE) ?? 'claude-code';
|
|
return getAllSessionsForAgentTypeFromDatabase(db, currentAgentType);
|
|
}
|
|
|
|
/**
|
|
* Get session for a specific agent type (cross-provider access).
|
|
* Used for provider switch probe attempts.
|
|
*/
|
|
export function getSessionForAgentType(
|
|
groupFolder: string,
|
|
agentType: string,
|
|
): string | undefined {
|
|
return getSessionFromDatabase(db, groupFolder, agentType);
|
|
}
|
|
|
|
/**
|
|
* Save session for a specific agent type without affecting current service's session.
|
|
* Used when probe succeeds and we want to save to target provider's slot only.
|
|
*/
|
|
export function setSessionForAgentType(
|
|
groupFolder: string,
|
|
agentType: string,
|
|
sessionId: string,
|
|
): void {
|
|
setSessionInDatabase(db, groupFolder, agentType, sessionId);
|
|
}
|
|
|
|
/**
|
|
* Get the agent type of the most recent bot response in a chat.
|
|
* Used to detect provider switches for delta handoff.
|
|
*/
|
|
export function getLastRespondingAgentType(
|
|
chatJid: string,
|
|
): AgentType | undefined {
|
|
return getLastRespondingAgentTypeFromDatabase(db, chatJid);
|
|
}
|
|
|
|
// --- Registered group accessors ---
|
|
|
|
export function getRegisteredGroup(
|
|
jid: string,
|
|
agentType?: string,
|
|
): (RegisteredGroup & { jid: string }) | undefined {
|
|
const requestedAgentType = normalizeStoredAgentType(agentType);
|
|
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
|
if (stored) {
|
|
return buildRegisteredGroupFromStoredSettings(
|
|
db,
|
|
stored,
|
|
requestedAgentType,
|
|
);
|
|
}
|
|
return getLegacyRegisteredGroup(db, jid, agentType);
|
|
}
|
|
|
|
function writeLegacyRegisteredGroupAndSyncRoomSettings(
|
|
jid: string,
|
|
group: RegisteredGroup,
|
|
): void {
|
|
const existingStored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
|
const existingRoomMode = getStoredRoomModeRow(jid);
|
|
if (!isValidGroupFolder(group.folder)) {
|
|
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
|
}
|
|
const tx = db.transaction(() => {
|
|
const seededAgentType = group.agentType || 'claude-code';
|
|
const agentTypes = new Set<AgentType>(collectRegisteredAgentTypes(db, jid));
|
|
agentTypes.add(seededAgentType);
|
|
const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([
|
|
...agentTypes,
|
|
]);
|
|
const roomMode =
|
|
existingRoomMode?.source === 'explicit'
|
|
? existingRoomMode.roomMode
|
|
: inferredRoomMode;
|
|
const ownerAgentType =
|
|
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType
|
|
? existingStored.ownerAgentType
|
|
: inferOwnerAgentTypeFromRegisteredAgentTypes([...agentTypes]);
|
|
const snapshot: RoomRegistrationSnapshot = {
|
|
name: group.name,
|
|
folder: group.folder,
|
|
triggerPattern:
|
|
existingStored?.modeSource === 'explicit' && existingStored.trigger
|
|
? existingStored.trigger
|
|
: group.trigger,
|
|
requiresTrigger:
|
|
group.requiresTrigger ?? existingStored?.requiresTrigger ?? true,
|
|
isMain: group.isMain ?? existingStored?.isMain ?? false,
|
|
ownerAgentType,
|
|
workDir: group.workDir ?? existingStored?.workDir ?? null,
|
|
};
|
|
|
|
if (existingStored) {
|
|
updateStoredRoomMetadata(db, jid, snapshot);
|
|
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
|
|
upsertStoredRoomMode(jid, roomMode, 'inferred');
|
|
}
|
|
} else {
|
|
insertStoredRoomSettings(db, jid, roomMode, 'inferred', snapshot);
|
|
}
|
|
|
|
materializeRegisteredGroupsForRoom(
|
|
db,
|
|
jid,
|
|
snapshot,
|
|
roomMode,
|
|
ownerAgentType,
|
|
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
|
|
group.added_at,
|
|
);
|
|
});
|
|
tx();
|
|
}
|
|
|
|
/**
|
|
* @internal Test/migration helper for seeding legacy capability rows.
|
|
* Runtime code must use assignRoom() so room_settings remains the explicit SSOT.
|
|
*/
|
|
export function _setRegisteredGroupForTests(
|
|
jid: string,
|
|
group: RegisteredGroup,
|
|
): void {
|
|
writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group);
|
|
}
|
|
|
|
export function assignRoom(
|
|
chatJid: string,
|
|
input: AssignRoomInput,
|
|
): (RegisteredGroup & { jid: string }) | undefined {
|
|
const existing = getStoredRoomSettingsRowFromDatabase(db, chatJid);
|
|
const roomMode = input.roomMode || existing?.roomMode || 'single';
|
|
const ownerAgentType =
|
|
input.ownerAgentType || existing?.ownerAgentType || OWNER_AGENT_TYPE;
|
|
const folder = resolveAssignedRoomFolder(
|
|
db,
|
|
chatJid,
|
|
input.name,
|
|
input.folder,
|
|
);
|
|
const snapshot: RoomRegistrationSnapshot = {
|
|
name: input.name,
|
|
folder,
|
|
triggerPattern: input.trigger || existing?.trigger || `@${ASSISTANT_NAME}`,
|
|
requiresTrigger: input.requiresTrigger ?? existing?.requiresTrigger ?? true,
|
|
isMain: input.isMain ?? existing?.isMain ?? false,
|
|
ownerAgentType,
|
|
workDir: input.workDir ?? existing?.workDir ?? null,
|
|
};
|
|
const now = new Date().toISOString();
|
|
|
|
db.transaction(() => {
|
|
if (existing) {
|
|
db.prepare(
|
|
`UPDATE room_settings
|
|
SET room_mode = ?,
|
|
mode_source = 'explicit',
|
|
name = ?,
|
|
folder = ?,
|
|
trigger_pattern = ?,
|
|
requires_trigger = ?,
|
|
is_main = ?,
|
|
owner_agent_type = ?,
|
|
work_dir = ?,
|
|
updated_at = ?
|
|
WHERE chat_jid = ?`,
|
|
).run(
|
|
roomMode,
|
|
snapshot.name,
|
|
snapshot.folder,
|
|
snapshot.triggerPattern,
|
|
snapshot.requiresTrigger ? 1 : 0,
|
|
snapshot.isMain ? 1 : 0,
|
|
snapshot.ownerAgentType,
|
|
snapshot.workDir,
|
|
now,
|
|
chatJid,
|
|
);
|
|
} else {
|
|
insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot);
|
|
}
|
|
|
|
materializeRegisteredGroupsForRoom(
|
|
db,
|
|
chatJid,
|
|
snapshot,
|
|
roomMode,
|
|
ownerAgentType,
|
|
input.ownerAgentConfig,
|
|
input.addedAt ?? now,
|
|
);
|
|
})();
|
|
|
|
return getRegisteredGroup(chatJid);
|
|
}
|
|
|
|
export function updateRegisteredGroupName(jid: string, name: string): void {
|
|
const plan = buildRoomRegistrationPlanForJid(jid, { name });
|
|
if (!plan) {
|
|
return;
|
|
}
|
|
db.transaction(() => {
|
|
if (plan.hasStoredRoom) {
|
|
updateStoredRoomMetadata(db, jid, plan.snapshot);
|
|
} else {
|
|
insertStoredRoomSettings(
|
|
db,
|
|
jid,
|
|
plan.roomMode,
|
|
'inferred',
|
|
plan.snapshot,
|
|
);
|
|
}
|
|
materializeRegisteredGroupsForRoom(
|
|
db,
|
|
jid,
|
|
plan.snapshot,
|
|
plan.roomMode,
|
|
plan.snapshot.ownerAgentType,
|
|
);
|
|
})();
|
|
}
|
|
|
|
export function getAllRegisteredGroups(
|
|
agentTypeFilter?: string,
|
|
): Record<string, RegisteredGroup> {
|
|
const result: Record<string, RegisteredGroup> = {};
|
|
const requestedAgentType = normalizeStoredAgentType(agentTypeFilter);
|
|
const storedRows = getStoredRoomRowsFromDatabase(db);
|
|
|
|
for (const stored of storedRows) {
|
|
const group = buildRegisteredGroupFromStoredSettings(
|
|
db,
|
|
stored,
|
|
requestedAgentType,
|
|
);
|
|
if (group) {
|
|
const { jid, ...rest } = group;
|
|
result[jid] = rest;
|
|
}
|
|
}
|
|
|
|
for (const legacyRow of getLegacyRegisteredGroupRows(db, agentTypeFilter)) {
|
|
if (result[legacyRow.jid]) continue;
|
|
const group = parseRegisteredGroupRow(legacyRow);
|
|
if (group) {
|
|
const { jid, ...rest } = group;
|
|
result[jid] = rest;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
|
|
if (!db) return [];
|
|
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
|
if (stored) {
|
|
return resolveStoredRoomCapabilityTypes(db, stored);
|
|
}
|
|
return collectRegisteredAgentTypes(db, jid);
|
|
}
|
|
|
|
/**
|
|
* Internal registration/backfill helper.
|
|
* This infers the stored room mode from current registrations and must not be
|
|
* treated as runtime source-of-truth by callers.
|
|
*/
|
|
function inferStoredRoomModeForJid(jid: string): RoomMode {
|
|
return inferRoomModeFromRegisteredAgentTypes(
|
|
getRegisteredAgentTypesForJid(jid),
|
|
);
|
|
}
|
|
|
|
export function getStoredRoomSettings(
|
|
chatJid: string,
|
|
): StoredRoomSettings | undefined {
|
|
if (!db) return undefined;
|
|
return getStoredRoomSettingsRowFromDatabase(db, chatJid);
|
|
}
|
|
|
|
function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
|
|
const row = getStoredRoomSettings(chatJid);
|
|
return row
|
|
? {
|
|
roomMode: row.roomMode,
|
|
source: row.modeSource,
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
|
|
const existingSettings = getStoredRoomSettingsRowFromDatabase(db, chatJid);
|
|
const snapshot = collectRoomRegistrationSnapshot(
|
|
db,
|
|
chatJid,
|
|
existingSettings,
|
|
);
|
|
if (!snapshot) return;
|
|
|
|
if (!existingSettings) {
|
|
insertStoredRoomSettings(
|
|
db,
|
|
chatJid,
|
|
inferRoomModeFromRegisteredAgentTypes(
|
|
getRegisteredAgentTypesForJid(chatJid),
|
|
),
|
|
'inferred',
|
|
snapshot,
|
|
);
|
|
return;
|
|
}
|
|
|
|
updateStoredRoomMetadata(db, chatJid, snapshot);
|
|
}
|
|
|
|
function buildRoomRegistrationSnapshotFromStoredRoom(
|
|
stored: StoredRoomSettings,
|
|
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
|
|
): RoomRegistrationSnapshot {
|
|
const capabilityTypes = resolveStoredRoomCapabilityTypes(db, stored);
|
|
const ownerAgentType =
|
|
stored.ownerAgentType ||
|
|
(capabilityTypes.length > 0
|
|
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
|
|
: OWNER_AGENT_TYPE);
|
|
const name = overrides?.name ?? stored.name ?? stored.chatJid;
|
|
|
|
return {
|
|
name,
|
|
folder: resolveAssignedRoomFolder(db, stored.chatJid, name, stored.folder),
|
|
triggerPattern: stored.trigger || `@${ASSISTANT_NAME}`,
|
|
requiresTrigger: stored.requiresTrigger ?? true,
|
|
isMain: stored.isMain ?? false,
|
|
ownerAgentType,
|
|
workDir: stored.workDir ?? null,
|
|
};
|
|
}
|
|
|
|
function buildRoomRegistrationPlanForJid(
|
|
chatJid: string,
|
|
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
|
|
):
|
|
| {
|
|
snapshot: RoomRegistrationSnapshot;
|
|
roomMode: RoomMode;
|
|
hasStoredRoom: boolean;
|
|
}
|
|
| undefined {
|
|
const stored = getStoredRoomSettingsRowFromDatabase(db, chatJid);
|
|
if (stored) {
|
|
return {
|
|
snapshot: buildRoomRegistrationSnapshotFromStoredRoom(stored, overrides),
|
|
roomMode: stored.roomMode,
|
|
hasStoredRoom: true,
|
|
};
|
|
}
|
|
|
|
const snapshot = collectRoomRegistrationSnapshot(db, chatJid);
|
|
if (!snapshot) return undefined;
|
|
|
|
return {
|
|
snapshot: {
|
|
...snapshot,
|
|
name: overrides?.name ?? snapshot.name,
|
|
},
|
|
roomMode: inferRoomModeFromRegisteredAgentTypes(
|
|
collectRegisteredAgentTypes(db, chatJid),
|
|
),
|
|
hasStoredRoom: false,
|
|
};
|
|
}
|
|
|
|
function upsertStoredRoomMode(
|
|
chatJid: string,
|
|
roomMode: RoomMode,
|
|
source: RoomModeSource,
|
|
): void {
|
|
db.prepare(
|
|
`INSERT INTO room_settings (
|
|
chat_jid,
|
|
room_mode,
|
|
mode_source,
|
|
updated_at
|
|
) VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(chat_jid) DO UPDATE SET
|
|
room_mode = excluded.room_mode,
|
|
mode_source = excluded.mode_source,
|
|
updated_at = excluded.updated_at`,
|
|
).run(chatJid, roomMode, source, new Date().toISOString());
|
|
}
|
|
|
|
function syncInferredRoomModeForJid(chatJid: string): void {
|
|
upsertStoredRoomMode(chatJid, inferStoredRoomModeForJid(chatJid), 'inferred');
|
|
}
|
|
|
|
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
|
|
const row = getStoredRoomModeRow(chatJid);
|
|
return row?.source === 'explicit' ? row.roomMode : undefined;
|
|
}
|
|
|
|
export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
|
|
upsertStoredRoomMode(chatJid, roomMode, 'explicit');
|
|
}
|
|
|
|
export function clearExplicitRoomMode(chatJid: string): void {
|
|
const agentTypes = collectRegisteredAgentTypes(db, chatJid);
|
|
if (agentTypes.length === 0) {
|
|
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
|
|
return;
|
|
}
|
|
upsertStoredRoomMode(
|
|
chatJid,
|
|
inferRoomModeFromRegisteredAgentTypes(agentTypes),
|
|
'inferred',
|
|
);
|
|
}
|
|
|
|
export function getEffectiveRoomMode(chatJid: string): RoomMode {
|
|
return getStoredRoomModeRow(chatJid)?.roomMode ?? 'single';
|
|
}
|
|
|
|
function canRunTribunalFromRegisteredAgentTypes(
|
|
agentTypes: readonly AgentType[],
|
|
): boolean {
|
|
const types = new Set(agentTypes);
|
|
if (types.size === 0) return false;
|
|
return REVIEWER_AGENT_TYPE === 'claude-code'
|
|
? types.has('claude-code')
|
|
: types.has('codex');
|
|
}
|
|
|
|
export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode {
|
|
const stored = getStoredRoomSettings(chatJid);
|
|
if (stored) {
|
|
return stored.roomMode;
|
|
}
|
|
|
|
return inferStoredRoomModeForJid(chatJid) === 'tribunal' &&
|
|
canRunTribunalFromRegisteredAgentTypes(
|
|
getRegisteredAgentTypesForJid(chatJid),
|
|
)
|
|
? 'tribunal'
|
|
: 'single';
|
|
}
|
|
|
|
// --- Paired task/project/workspace state ---
|
|
|
|
export function upsertPairedProject(project: PairedProject): void {
|
|
upsertPairedProjectInDatabase(db, project);
|
|
}
|
|
|
|
export function getPairedProject(chatJid: string): PairedProject | undefined {
|
|
return getPairedProjectFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function createPairedTask(task: PairedTask): void {
|
|
createPairedTaskInDatabase(db, task);
|
|
}
|
|
|
|
export function getPairedTaskById(id: string): PairedTask | undefined {
|
|
return getPairedTaskByIdFromDatabase(db, id);
|
|
}
|
|
|
|
export function getLatestPairedTaskForChat(
|
|
chatJid: string,
|
|
): PairedTask | undefined {
|
|
return getLatestPairedTaskForChatFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function getLatestOpenPairedTaskForChat(
|
|
chatJid: string,
|
|
): PairedTask | undefined {
|
|
return getLatestOpenPairedTaskForChatFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function updatePairedTask(id: string, updates: PairedTaskUpdates): void {
|
|
updatePairedTaskInDatabase(db, id, updates);
|
|
}
|
|
|
|
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
|
upsertPairedWorkspaceInDatabase(db, workspace);
|
|
}
|
|
|
|
export function getPairedWorkspace(
|
|
taskId: string,
|
|
role: PairedWorkspace['role'],
|
|
): PairedWorkspace | undefined {
|
|
return getPairedWorkspaceFromDatabase(db, taskId, role);
|
|
}
|
|
|
|
export function listPairedWorkspacesForTask(taskId: string): PairedWorkspace[] {
|
|
return listPairedWorkspacesForTaskFromDatabase(db, taskId);
|
|
}
|
|
|
|
/**
|
|
* Get the most recent bot message (is_bot_message=1) in a chat, regardless of which bot sent it.
|
|
* Used for duplicate detection in pair rooms.
|
|
*/
|
|
export function getLastBotFinalMessage(
|
|
chatJid: string,
|
|
agentType: AgentType = 'claude-code',
|
|
limit: number = 1,
|
|
): Array<{ content: string; timestamp: string }> {
|
|
return getLastBotFinalMessageFromDatabase(db, chatJid, agentType, limit);
|
|
}
|
|
|
|
// --- Channel owner lease accessors ---
|
|
|
|
export function getChannelOwnerLease(
|
|
chatJid: string,
|
|
): ChannelOwnerLeaseRow | undefined {
|
|
return getChannelOwnerLeaseFromDatabase(db, chatJid);
|
|
}
|
|
|
|
export function getAllChannelOwnerLeases(): ChannelOwnerLeaseRow[] {
|
|
return getAllChannelOwnerLeasesFromDatabase(db);
|
|
}
|
|
|
|
export function setChannelOwnerLease(input: SetChannelOwnerLeaseInput): void {
|
|
setChannelOwnerLeaseInDatabase(db, input);
|
|
}
|
|
|
|
export function clearChannelOwnerLease(chatJid: string): void {
|
|
clearChannelOwnerLeaseInDatabase(db, chatJid);
|
|
}
|
|
|
|
// --- Cross-service handoff accessors ---
|
|
|
|
export function createServiceHandoff(
|
|
input: CreateServiceHandoffInput,
|
|
): ServiceHandoff {
|
|
return createServiceHandoffInDatabase(db, input);
|
|
}
|
|
|
|
export function getPendingServiceHandoffs(
|
|
targetServiceId: string = SERVICE_SESSION_SCOPE,
|
|
): ServiceHandoff[] {
|
|
return getPendingServiceHandoffsFromDatabase(db, targetServiceId);
|
|
}
|
|
|
|
export function getAllPendingServiceHandoffs(): ServiceHandoff[] {
|
|
return getAllPendingServiceHandoffsFromDatabase(db);
|
|
}
|
|
|
|
export function claimServiceHandoff(id: number): boolean {
|
|
return claimServiceHandoffInDatabase(db, id);
|
|
}
|
|
|
|
export function completeServiceHandoff(id: number): void {
|
|
completeServiceHandoffInDatabase(db, id);
|
|
}
|
|
|
|
export function failServiceHandoff(id: number, error: string): void {
|
|
failServiceHandoffInDatabase(db, id, error);
|
|
}
|
|
|
|
export function completeServiceHandoffAndAdvanceTargetCursor(
|
|
input: CompleteServiceHandoffCursorInput,
|
|
): string | null {
|
|
return completeServiceHandoffAndAdvanceTargetCursorInDatabase(db, input);
|
|
}
|
|
|
|
export function insertPairedTurnOutput(
|
|
taskId: string,
|
|
turnNumber: number,
|
|
role: PairedRoomRole,
|
|
outputText: string,
|
|
): void {
|
|
insertPairedTurnOutputInDatabase(db, taskId, turnNumber, role, outputText);
|
|
}
|
|
|
|
export function getPairedTurnOutputs(taskId: string): PairedTurnOutput[] {
|
|
return getPairedTurnOutputsFromDatabase(db, taskId);
|
|
}
|
|
|
|
export function getLatestTurnNumber(taskId: string): number {
|
|
return getLatestTurnNumberFromDatabase(db, taskId);
|
|
}
|