Merge branch 'codex/owner/ejclaw'
This commit is contained in:
@@ -9,8 +9,11 @@ const { mockReadEnvFile, mockGetActiveCodexAuthPath } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
||||
GROUPS_DIR: '/tmp/ejclaw-test-groups',
|
||||
IS_TEST_ENV: true,
|
||||
LOG_LEVEL: 'info',
|
||||
SERVICE_ID: 'codex-main',
|
||||
SERVICE_SESSION_SCOPE: 'codex-main',
|
||||
TIMEZONE: 'Asia/Seoul',
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/ejclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
LOG_LEVEL: 'info',
|
||||
SERVICE_ID: 'claude',
|
||||
SERVICE_SESSION_SCOPE: 'claude',
|
||||
TIMEZONE: 'America/Los_Angeles',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AGENT_MAX_OUTPUT_SIZE,
|
||||
AGENT_TIMEOUT,
|
||||
IDLE_TIMEOUT,
|
||||
LOG_LEVEL,
|
||||
} from './config.js';
|
||||
import {
|
||||
prepareReadonlySessionEnvironment,
|
||||
@@ -411,8 +412,7 @@ export async function runAgentProcess(
|
||||
logsDir,
|
||||
`agent-${input.runId || 'adhoc'}-${timestamp}.log`,
|
||||
);
|
||||
const isVerbose =
|
||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
const isVerbose = LOG_LEVEL === 'debug' || LOG_LEVEL === 'trace';
|
||||
|
||||
const logLines = [
|
||||
`=== Agent Run Log ===`,
|
||||
|
||||
97
src/config.test.ts
Normal file
97
src/config.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('config/env loading', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
let previousEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-config-'));
|
||||
previousCwd = process.cwd();
|
||||
previousEnv = { ...process.env };
|
||||
process.chdir(tempRoot);
|
||||
delete process.env.EJCLAW_STORE_DIR;
|
||||
delete process.env.EJCLAW_GROUPS_DIR;
|
||||
delete process.env.EJCLAW_DATA_DIR;
|
||||
delete process.env.EJCLAW_CACHE_DIR;
|
||||
delete process.env.AGENT_TIMEOUT;
|
||||
delete process.env.LOG_LEVEL;
|
||||
delete process.env.CLAUDE_MODEL;
|
||||
delete process.env.CODEX_MODEL;
|
||||
delete process.env.STATUS_CHANNEL_ID;
|
||||
delete process.env.TZ;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in previousEnv)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('preserves empty-string env values and still prefers process.env over .env', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, '.env'),
|
||||
[
|
||||
'FROM_FILE=file-value',
|
||||
'EMPTY_IN_FILE=',
|
||||
'PROCESS_EMPTY=file-fallback',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env.FROM_FILE = 'process-value';
|
||||
process.env.PROCESS_EMPTY = '';
|
||||
|
||||
const env = await import('./env.js');
|
||||
|
||||
expect(env.getEnv('FROM_FILE')).toBe('process-value');
|
||||
expect(env.getEnv('EMPTY_IN_FILE')).toBe('');
|
||||
expect(env.getEnv('PROCESS_EMPTY')).toBe('');
|
||||
expect(env.readEnvFile(['EMPTY_IN_FILE', 'PROCESS_EMPTY'])).toEqual({
|
||||
EMPTY_IN_FILE: '',
|
||||
PROCESS_EMPTY: 'file-fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads formerly process-only config values through the shared env path', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, '.env'),
|
||||
[
|
||||
'EJCLAW_STORE_DIR=./custom-store',
|
||||
'EJCLAW_GROUPS_DIR=./custom-groups',
|
||||
'EJCLAW_DATA_DIR=./custom-data',
|
||||
'EJCLAW_CACHE_DIR=./custom-cache',
|
||||
'AGENT_TIMEOUT=12345',
|
||||
'LOG_LEVEL=trace',
|
||||
'CLAUDE_MODEL=claude-opus-test',
|
||||
'CODEX_MODEL=gpt-5.4-test',
|
||||
'STATUS_CHANNEL_ID=status-room',
|
||||
'TZ=UTC',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const config = await import('./config.js');
|
||||
|
||||
expect(config.STORE_DIR).toBe(path.resolve(tempRoot, 'custom-store'));
|
||||
expect(config.GROUPS_DIR).toBe(path.resolve(tempRoot, 'custom-groups'));
|
||||
expect(config.DATA_DIR).toBe(path.resolve(tempRoot, 'custom-data'));
|
||||
expect(config.CACHE_DIR).toBe(path.resolve(tempRoot, 'custom-cache'));
|
||||
expect(config.AGENT_TIMEOUT).toBe(12345);
|
||||
expect(config.LOG_LEVEL).toBe('trace');
|
||||
expect(config.DEFAULT_CLAUDE_MODEL).toBe('claude-opus-test');
|
||||
expect(config.DEFAULT_CODEX_MODEL).toBe('gpt-5.4-test');
|
||||
expect(config.STATUS_CHANNEL_ID).toBe('status-room');
|
||||
expect(config.TIMEZONE).toBe('UTC');
|
||||
});
|
||||
});
|
||||
243
src/config.ts
243
src/config.ts
@@ -1,19 +1,18 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { loadConfig } from './config/load-config.js';
|
||||
import type { MoaConfig } from './moa.js';
|
||||
import type { RoleModelConfig } from './config/schema.js';
|
||||
|
||||
import type { AgentType } from './types.js';
|
||||
import { getEnv } from './env.js';
|
||||
export type { AppConfig, RoleModelConfig } from './config/schema.js';
|
||||
export { loadConfig } from './config/load-config.js';
|
||||
|
||||
export const ASSISTANT_NAME = getEnv('ASSISTANT_NAME') || 'Andy';
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
getEnv('ASSISTANT_HAS_OWN_NUMBER') === 'true';
|
||||
const ASSISTANT_SLUG = ASSISTANT_NAME.trim().toLowerCase();
|
||||
export const SERVICE_ID = getEnv('SERVICE_ID') || ASSISTANT_SLUG;
|
||||
export const CLAUDE_SERVICE_ID = getEnv('CLAUDE_SERVICE_ID') || 'claude';
|
||||
export const CODEX_MAIN_SERVICE_ID =
|
||||
getEnv('CODEX_MAIN_SERVICE_ID') || 'codex-main';
|
||||
export const CODEX_REVIEW_SERVICE_ID =
|
||||
getEnv('CODEX_REVIEW_SERVICE_ID') || 'codex-review';
|
||||
const CONFIG = loadConfig();
|
||||
|
||||
export const ASSISTANT_NAME = CONFIG.assistant.name;
|
||||
export const ASSISTANT_HAS_OWN_NUMBER = CONFIG.assistant.hasOwnNumber;
|
||||
export const SERVICE_ID = CONFIG.service.id;
|
||||
export const CLAUDE_SERVICE_ID = CONFIG.service.claudeId;
|
||||
export const CODEX_MAIN_SERVICE_ID = CONFIG.service.codexMainId;
|
||||
export const CODEX_REVIEW_SERVICE_ID = CONFIG.service.codexReviewId;
|
||||
|
||||
export function normalizeServiceId(serviceId: string): string {
|
||||
if (serviceId === 'codex') {
|
||||
@@ -35,94 +34,52 @@ export function isCodexMainService(serviceId: string = SERVICE_ID): boolean {
|
||||
export function isReviewService(serviceId: string = SERVICE_ID): boolean {
|
||||
return normalizeServiceId(serviceId) === CODEX_REVIEW_SERVICE_ID;
|
||||
}
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
export const POLL_INTERVAL = CONFIG.runtime.pollInterval;
|
||||
export const SCHEDULER_POLL_INTERVAL = CONFIG.runtime.schedulerPollInterval;
|
||||
|
||||
/** Minimum time (ms) a failover lease stays active before Claude can reclaim it. */
|
||||
export const FAILOVER_MIN_DURATION_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
export const FAILOVER_MIN_DURATION_MS = CONFIG.runtime.failoverMinDurationMs;
|
||||
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
const HOME_DIR = process.env.HOME || os.homedir();
|
||||
export const SENDER_ALLOWLIST_PATH = CONFIG.paths.senderAllowlistPath;
|
||||
export const STORE_DIR = CONFIG.paths.storeDir;
|
||||
export const GROUPS_DIR = CONFIG.paths.groupsDir;
|
||||
export const DATA_DIR = CONFIG.paths.dataDir;
|
||||
export const CACHE_DIR = CONFIG.paths.cacheDir;
|
||||
|
||||
export const SENDER_ALLOWLIST_PATH = path.join(
|
||||
HOME_DIR,
|
||||
'.config',
|
||||
'ejclaw',
|
||||
'sender-allowlist.json',
|
||||
);
|
||||
export const STORE_DIR = path.resolve(
|
||||
process.env.EJCLAW_STORE_DIR || path.join(PROJECT_ROOT, 'store'),
|
||||
);
|
||||
export const GROUPS_DIR = path.resolve(
|
||||
process.env.EJCLAW_GROUPS_DIR || path.join(PROJECT_ROOT, 'groups'),
|
||||
);
|
||||
export const DATA_DIR = path.resolve(
|
||||
process.env.EJCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'),
|
||||
);
|
||||
// Shared cache directory (same across both services for dedup)
|
||||
export const CACHE_DIR = path.join(PROJECT_ROOT, 'cache');
|
||||
|
||||
export const AGENT_TIMEOUT = parseInt(
|
||||
process.env.AGENT_TIMEOUT || '1800000',
|
||||
10,
|
||||
);
|
||||
export const AGENT_MAX_OUTPUT_SIZE = parseInt(
|
||||
process.env.AGENT_MAX_OUTPUT_SIZE || '10485760',
|
||||
10,
|
||||
); // 10MB default
|
||||
export const IPC_POLL_INTERVAL = 1000;
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep agent alive after last result
|
||||
export const MAX_CONCURRENT_AGENTS = Math.max(
|
||||
1,
|
||||
parseInt(process.env.MAX_CONCURRENT_AGENTS || '5', 10) || 5,
|
||||
);
|
||||
export const RECOVERY_CONCURRENT_AGENTS = parseInt(
|
||||
getEnv('RECOVERY_CONCURRENT_AGENTS') || '3',
|
||||
10,
|
||||
);
|
||||
export const AGENT_TIMEOUT = CONFIG.runtime.agentTimeout;
|
||||
export const AGENT_MAX_OUTPUT_SIZE = CONFIG.runtime.agentMaxOutputSize;
|
||||
export const IPC_POLL_INTERVAL = CONFIG.runtime.ipcPollInterval;
|
||||
export const IDLE_TIMEOUT = CONFIG.runtime.idleTimeout;
|
||||
export const MAX_CONCURRENT_AGENTS = CONFIG.runtime.maxConcurrentAgents;
|
||||
export const RECOVERY_CONCURRENT_AGENTS =
|
||||
CONFIG.runtime.recoveryConcurrentAgents;
|
||||
export const LOG_LEVEL = CONFIG.logging.level;
|
||||
export const IS_TEST_ENV = CONFIG.logging.isTestEnv;
|
||||
|
||||
// ── Paired review ─────────────────────────────────────────────────
|
||||
|
||||
/** Owner agent type. Default: codex. Set OWNER_AGENT_TYPE=claude-code to use Claude as owner. */
|
||||
const rawOwnerAgentType = getEnv('OWNER_AGENT_TYPE');
|
||||
export const OWNER_AGENT_TYPE: AgentType =
|
||||
rawOwnerAgentType === 'codex' || rawOwnerAgentType === 'claude-code'
|
||||
? rawOwnerAgentType
|
||||
: 'codex';
|
||||
export const OWNER_AGENT_TYPE = CONFIG.paired.ownerAgentType;
|
||||
|
||||
/** Reviewer agent type. Default: claude-code. Set REVIEWER_AGENT_TYPE=codex to use Codex as reviewer. */
|
||||
const rawReviewerAgentType = getEnv('REVIEWER_AGENT_TYPE');
|
||||
export const REVIEWER_AGENT_TYPE: AgentType =
|
||||
rawReviewerAgentType === 'codex' || rawReviewerAgentType === 'claude-code'
|
||||
? rawReviewerAgentType
|
||||
: 'claude-code';
|
||||
export const REVIEWER_AGENT_TYPE = CONFIG.paired.reviewerAgentType;
|
||||
|
||||
/** Service ID for the reviewer based on agent type. */
|
||||
export const REVIEWER_SERVICE_ID_FOR_TYPE =
|
||||
REVIEWER_AGENT_TYPE === 'claude-code'
|
||||
? CLAUDE_SERVICE_ID
|
||||
: CODEX_REVIEW_SERVICE_ID;
|
||||
CONFIG.paired.reviewerServiceIdForType;
|
||||
|
||||
/** Arbiter agent type. Disabled by default. Set ARBITER_AGENT_TYPE=codex or claude-code to enable. */
|
||||
const rawArbiterAgentType = getEnv('ARBITER_AGENT_TYPE');
|
||||
export const ARBITER_AGENT_TYPE: AgentType | undefined =
|
||||
rawArbiterAgentType === 'codex' || rawArbiterAgentType === 'claude-code'
|
||||
? rawArbiterAgentType
|
||||
: undefined;
|
||||
export const ARBITER_AGENT_TYPE = CONFIG.paired.arbiterAgentType;
|
||||
|
||||
/** Service ID for the arbiter. Defaults to codex-review for internal routing when arbiter is enabled. */
|
||||
export const ARBITER_SERVICE_ID = ARBITER_AGENT_TYPE
|
||||
? getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID
|
||||
: null;
|
||||
export const ARBITER_SERVICE_ID = CONFIG.paired.arbiterServiceId;
|
||||
|
||||
/** Language for agent responses. When set, a language instruction is appended to all paired room prompts. */
|
||||
export const AGENT_LANGUAGE = getEnv('AGENT_LANGUAGE') || '';
|
||||
export const AGENT_LANGUAGE = CONFIG.paired.agentLanguage;
|
||||
|
||||
/** Number of consecutive owner↔reviewer round trips before arbiter is auto-requested. */
|
||||
export const ARBITER_DEADLOCK_THRESHOLD = parseInt(
|
||||
getEnv('ARBITER_DEADLOCK_THRESHOLD') || '2',
|
||||
10,
|
||||
);
|
||||
export const ARBITER_DEADLOCK_THRESHOLD =
|
||||
CONFIG.paired.arbiterDeadlockThreshold;
|
||||
|
||||
export function isArbiterEnabled(): boolean {
|
||||
return ARBITER_AGENT_TYPE !== undefined;
|
||||
@@ -130,26 +87,11 @@ export function isArbiterEnabled(): boolean {
|
||||
|
||||
// ── Per-role model configuration ─────────────────────────────────
|
||||
|
||||
export interface RoleModelConfig {
|
||||
/** Model name override (e.g. 'claude-opus-4-6', 'gpt-5.4'). */
|
||||
model?: string;
|
||||
/** Effort level override. */
|
||||
effort?: string;
|
||||
/** Whether to fall back to codex when primary provider fails. Default: true. */
|
||||
fallbackEnabled: boolean;
|
||||
}
|
||||
|
||||
function buildRoleModelConfig(envPrefix: string): RoleModelConfig {
|
||||
return {
|
||||
model: getEnv(`${envPrefix}_MODEL`) || undefined,
|
||||
effort: getEnv(`${envPrefix}_EFFORT`) || undefined,
|
||||
fallbackEnabled: getEnv(`${envPrefix}_FALLBACK_ENABLED`) !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
export const OWNER_MODEL_CONFIG = buildRoleModelConfig('OWNER');
|
||||
export const REVIEWER_MODEL_CONFIG = buildRoleModelConfig('REVIEWER');
|
||||
export const ARBITER_MODEL_CONFIG = buildRoleModelConfig('ARBITER');
|
||||
export const OWNER_MODEL_CONFIG = CONFIG.models.owner;
|
||||
export const REVIEWER_MODEL_CONFIG = CONFIG.models.reviewer;
|
||||
export const ARBITER_MODEL_CONFIG = CONFIG.models.arbiter;
|
||||
export const DEFAULT_CLAUDE_MODEL = CONFIG.providers.claudeDefaultModel;
|
||||
export const DEFAULT_CODEX_MODEL = CONFIG.providers.codexDefaultModel;
|
||||
|
||||
export function getRoleModelConfig(
|
||||
role: 'owner' | 'reviewer' | 'arbiter',
|
||||
@@ -166,107 +108,38 @@ export function getRoleModelConfig(
|
||||
|
||||
// ── Mixture of Agents (MoA) ──────────────────────────────────────
|
||||
|
||||
import type { MoaConfig, MoaModelConfig } from './moa.js';
|
||||
|
||||
/**
|
||||
* Parse MOA reference models from env.
|
||||
* Format: MOA_REF_MODELS=kimi,glm (comma-separated names)
|
||||
* Each model: MOA_{NAME}_MODEL, MOA_{NAME}_BASE_URL, MOA_{NAME}_API_KEY
|
||||
*/
|
||||
function parseMoaReferenceModels(): MoaModelConfig[] {
|
||||
const names = (getEnv('MOA_REF_MODELS') || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return names
|
||||
.map((name) => {
|
||||
const prefix = `MOA_${name.toUpperCase()}`;
|
||||
const model = getEnv(`${prefix}_MODEL`) || '';
|
||||
const baseUrl = getEnv(`${prefix}_BASE_URL`) || '';
|
||||
const apiKey = getEnv(`${prefix}_API_KEY`) || '';
|
||||
if (!model || !baseUrl || !apiKey) return null;
|
||||
const rawFormat = getEnv(`${prefix}_API_FORMAT`) || '';
|
||||
const apiFormat: 'openai' | 'anthropic' =
|
||||
rawFormat === 'anthropic' ? 'anthropic' : 'openai';
|
||||
return { name, model, baseUrl, apiKey, apiFormat };
|
||||
})
|
||||
.filter((m): m is MoaModelConfig => m !== null);
|
||||
}
|
||||
|
||||
export function getMoaConfig(): MoaConfig {
|
||||
const referenceModels = parseMoaReferenceModels();
|
||||
return {
|
||||
enabled: getEnv('MOA_ENABLED') === 'true' && referenceModels.length > 0,
|
||||
referenceModels,
|
||||
};
|
||||
return CONFIG.moa;
|
||||
}
|
||||
|
||||
// Max owner↔reviewer round trips per task. 0 = unlimited.
|
||||
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '1000';
|
||||
export const PAIRED_MAX_ROUND_TRIPS =
|
||||
rawMaxRoundTrips === '0' ? Infinity : parseInt(rawMaxRoundTrips, 10) || 1000;
|
||||
export const PAIRED_MAX_ROUND_TRIPS = CONFIG.paired.maxRoundTrips;
|
||||
|
||||
export const RECOVERY_STAGGER_MS = parseInt(
|
||||
getEnv('RECOVERY_STAGGER_MS') || '2000',
|
||||
10,
|
||||
);
|
||||
export const RECOVERY_DURATION_MS = parseInt(
|
||||
getEnv('RECOVERY_DURATION_MS') || '60000',
|
||||
10,
|
||||
);
|
||||
export const RECOVERY_STAGGER_MS = CONFIG.runtime.recoveryStaggerMs;
|
||||
export const RECOVERY_DURATION_MS = CONFIG.runtime.recoveryDurationMs;
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export const TRIGGER_PATTERN = new RegExp(
|
||||
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
|
||||
'i',
|
||||
);
|
||||
export const TRIGGER_PATTERN = CONFIG.assistant.triggerPattern;
|
||||
|
||||
// Status dashboard: Discord channel ID for live agent status updates
|
||||
export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
|
||||
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
||||
export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes
|
||||
export const STATUS_SHOW_ROOMS =
|
||||
(getEnv('STATUS_SHOW_ROOMS') || 'true') !== 'false';
|
||||
export const STATUS_SHOW_ROOM_DETAILS =
|
||||
(getEnv('STATUS_SHOW_ROOM_DETAILS') || 'true') !== 'false';
|
||||
export const USAGE_DASHBOARD_ENABLED = getEnv('USAGE_DASHBOARD') === 'true';
|
||||
export const STATUS_CHANNEL_ID = CONFIG.status.channelId;
|
||||
export const STATUS_UPDATE_INTERVAL = CONFIG.status.updateInterval;
|
||||
export const USAGE_UPDATE_INTERVAL = CONFIG.status.usageUpdateInterval;
|
||||
export const STATUS_SHOW_ROOMS = CONFIG.status.showRooms;
|
||||
export const STATUS_SHOW_ROOM_DETAILS = CONFIG.status.showRoomDetails;
|
||||
export const USAGE_DASHBOARD_ENABLED = CONFIG.status.usageDashboardEnabled;
|
||||
|
||||
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||
// Uses system timezone by default
|
||||
export const TIMEZONE =
|
||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
export const TIMEZONE = CONFIG.status.timezone;
|
||||
|
||||
const rawSessionCommandAllowedSenders =
|
||||
getEnv('SESSION_COMMAND_ALLOWED_SENDERS') ||
|
||||
getEnv('SESSION_COMMAND_USER_IDS') ||
|
||||
'';
|
||||
|
||||
const SESSION_COMMAND_ALLOWED_SENDERS = new Set(
|
||||
rawSessionCommandAllowedSenders
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const SESSION_COMMAND_ALLOWED_SENDERS = CONFIG.sessionCommands.allowedSenders;
|
||||
|
||||
export function isSessionCommandSenderAllowed(sender: string): boolean {
|
||||
return SESSION_COMMAND_ALLOWED_SENDERS.has(sender);
|
||||
}
|
||||
|
||||
// Delta handoff: cross-provider session continuity with probe + fallback
|
||||
export const DELTA_HANDOFF_ENABLED = getEnv('DELTA_HANDOFF_ENABLED') === 'true';
|
||||
|
||||
// Comma-separated list of group folders for canary testing
|
||||
const rawDeltaHandoffCanaryGroups = getEnv('DELTA_HANDOFF_CANARY_GROUPS') || '';
|
||||
export const DELTA_HANDOFF_CANARY_GROUPS = new Set(
|
||||
rawDeltaHandoffCanaryGroups
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
export const DELTA_HANDOFF_ENABLED = CONFIG.deltaHandoff.enabled;
|
||||
export const DELTA_HANDOFF_CANARY_GROUPS = CONFIG.deltaHandoff.canaryGroups;
|
||||
|
||||
export function isDeltaHandoffEnabledForGroup(groupFolder: string): boolean {
|
||||
if (!DELTA_HANDOFF_ENABLED) return false;
|
||||
|
||||
2
src/config/index.ts
Normal file
2
src/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { AppConfig, RoleModelConfig } from './schema.js';
|
||||
export { loadConfig } from './load-config.js';
|
||||
240
src/config/load-config.ts
Normal file
240
src/config/load-config.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { MoaConfig, MoaModelConfig } from '../moa.js';
|
||||
import type { AgentType } from '../types.js';
|
||||
import { getEnv } from '../env.js';
|
||||
|
||||
import type { AppConfig, RoleModelConfig } from './schema.js';
|
||||
|
||||
function readText(key: string): string | undefined {
|
||||
return getEnv(key);
|
||||
}
|
||||
|
||||
function readNonEmptyText(key: string): string | undefined {
|
||||
const value = getEnv(key);
|
||||
return value === '' ? undefined : value;
|
||||
}
|
||||
|
||||
function readBoolean(key: string, fallback = false): boolean {
|
||||
const value = readText(key);
|
||||
if (value == null || value === '') return fallback;
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
function readBooleanUnlessFalse(key: string, fallback = true): boolean {
|
||||
const value = readText(key);
|
||||
if (value == null || value === '') return fallback;
|
||||
return value !== 'false';
|
||||
}
|
||||
|
||||
function readInteger(key: string, fallback: number): number {
|
||||
const value = readNonEmptyText(key);
|
||||
if (value == null) return fallback;
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function readIntegerAtLeast(
|
||||
key: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
): number {
|
||||
return Math.max(minimum, readInteger(key, fallback) || fallback);
|
||||
}
|
||||
|
||||
function readAgentType(
|
||||
key: string,
|
||||
fallback?: AgentType,
|
||||
): AgentType | undefined {
|
||||
const value = readText(key);
|
||||
if (value === 'codex' || value === 'claude-code') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildRoleModelConfig(envPrefix: string): RoleModelConfig {
|
||||
return {
|
||||
model: readNonEmptyText(`${envPrefix}_MODEL`),
|
||||
effort: readNonEmptyText(`${envPrefix}_EFFORT`),
|
||||
fallbackEnabled: readText(`${envPrefix}_FALLBACK_ENABLED`) !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
function parseMoaReferenceModels(): MoaModelConfig[] {
|
||||
const names = (readText('MOA_REF_MODELS') ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return names
|
||||
.map((name) => {
|
||||
const prefix = `MOA_${name.toUpperCase()}`;
|
||||
const model = readNonEmptyText(`${prefix}_MODEL`) ?? '';
|
||||
const baseUrl = readNonEmptyText(`${prefix}_BASE_URL`) ?? '';
|
||||
const apiKey = readNonEmptyText(`${prefix}_API_KEY`) ?? '';
|
||||
if (!model || !baseUrl || !apiKey) return null;
|
||||
const rawFormat = readText(`${prefix}_API_FORMAT`) ?? '';
|
||||
const apiFormat: 'openai' | 'anthropic' =
|
||||
rawFormat === 'anthropic' ? 'anthropic' : 'openai';
|
||||
return { name, model, baseUrl, apiKey, apiFormat };
|
||||
})
|
||||
.filter((value): value is MoaModelConfig => value !== null);
|
||||
}
|
||||
|
||||
function buildMoaConfig(): MoaConfig {
|
||||
const referenceModels = parseMoaReferenceModels();
|
||||
return {
|
||||
enabled: readText('MOA_ENABLED') === 'true' && referenceModels.length > 0,
|
||||
referenceModels,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function normalizeServiceId(
|
||||
serviceId: string,
|
||||
codexMainServiceId: string,
|
||||
): string {
|
||||
return serviceId === 'codex' ? codexMainServiceId : serviceId;
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const assistantName = readText('ASSISTANT_NAME') ?? 'Andy';
|
||||
const assistantSlug = assistantName.trim().toLowerCase();
|
||||
|
||||
const claudeServiceId = readText('CLAUDE_SERVICE_ID') ?? 'claude';
|
||||
const codexMainServiceId = readText('CODEX_MAIN_SERVICE_ID') ?? 'codex-main';
|
||||
const codexReviewServiceId =
|
||||
readText('CODEX_REVIEW_SERVICE_ID') ?? 'codex-review';
|
||||
const serviceId = readText('SERVICE_ID') ?? assistantSlug;
|
||||
const serviceSessionScope = normalizeServiceId(serviceId, codexMainServiceId);
|
||||
|
||||
const ownerAgentType = readAgentType('OWNER_AGENT_TYPE', 'codex') ?? 'codex';
|
||||
const reviewerAgentType =
|
||||
readAgentType('REVIEWER_AGENT_TYPE', 'claude-code') ?? 'claude-code';
|
||||
const arbiterAgentType = readAgentType('ARBITER_AGENT_TYPE');
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const homeDir = readNonEmptyText('HOME') ?? os.homedir();
|
||||
|
||||
const rawMaxRoundTrips = readText('PAIRED_MAX_ROUND_TRIPS');
|
||||
const maxRoundTrips =
|
||||
rawMaxRoundTrips === '0'
|
||||
? Infinity
|
||||
: readInteger('PAIRED_MAX_ROUND_TRIPS', 1000);
|
||||
|
||||
return {
|
||||
assistant: {
|
||||
name: assistantName,
|
||||
hasOwnNumber: readBoolean('ASSISTANT_HAS_OWN_NUMBER', false),
|
||||
slug: assistantSlug,
|
||||
triggerPattern: new RegExp(`^@${escapeRegex(assistantName)}\\b`, 'i'),
|
||||
},
|
||||
service: {
|
||||
id: serviceId,
|
||||
sessionScope: serviceSessionScope,
|
||||
claudeId: claudeServiceId,
|
||||
codexMainId: codexMainServiceId,
|
||||
codexReviewId: codexReviewServiceId,
|
||||
},
|
||||
paths: {
|
||||
projectRoot,
|
||||
homeDir,
|
||||
senderAllowlistPath: path.join(
|
||||
homeDir,
|
||||
'.config',
|
||||
'ejclaw',
|
||||
'sender-allowlist.json',
|
||||
),
|
||||
storeDir: path.resolve(
|
||||
readNonEmptyText('EJCLAW_STORE_DIR') ?? path.join(projectRoot, 'store'),
|
||||
),
|
||||
groupsDir: path.resolve(
|
||||
readNonEmptyText('EJCLAW_GROUPS_DIR') ??
|
||||
path.join(projectRoot, 'groups'),
|
||||
),
|
||||
dataDir: path.resolve(
|
||||
readNonEmptyText('EJCLAW_DATA_DIR') ?? path.join(projectRoot, 'data'),
|
||||
),
|
||||
cacheDir: path.resolve(
|
||||
readNonEmptyText('EJCLAW_CACHE_DIR') ?? path.join(projectRoot, 'cache'),
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
pollInterval: 2000,
|
||||
schedulerPollInterval: 60000,
|
||||
failoverMinDurationMs: 3 * 60 * 60 * 1000,
|
||||
agentTimeout: readInteger('AGENT_TIMEOUT', 1_800_000),
|
||||
agentMaxOutputSize: readInteger('AGENT_MAX_OUTPUT_SIZE', 10_485_760),
|
||||
ipcPollInterval: 1000,
|
||||
idleTimeout: readInteger('IDLE_TIMEOUT', 1_800_000),
|
||||
maxConcurrentAgents: readIntegerAtLeast('MAX_CONCURRENT_AGENTS', 5, 1),
|
||||
recoveryConcurrentAgents: readInteger('RECOVERY_CONCURRENT_AGENTS', 3),
|
||||
recoveryStaggerMs: readInteger('RECOVERY_STAGGER_MS', 2000),
|
||||
recoveryDurationMs: readInteger('RECOVERY_DURATION_MS', 60000),
|
||||
},
|
||||
logging: {
|
||||
level: readText('LOG_LEVEL') ?? 'info',
|
||||
isTestEnv:
|
||||
readText('VITEST') === 'true' || readText('NODE_ENV') === 'test',
|
||||
},
|
||||
paired: {
|
||||
ownerAgentType,
|
||||
reviewerAgentType,
|
||||
reviewerServiceIdForType:
|
||||
reviewerAgentType === 'claude-code'
|
||||
? claudeServiceId
|
||||
: codexReviewServiceId,
|
||||
arbiterAgentType,
|
||||
arbiterServiceId: arbiterAgentType
|
||||
? (readText('ARBITER_SERVICE_ID') ?? codexReviewServiceId)
|
||||
: null,
|
||||
agentLanguage: readText('AGENT_LANGUAGE') ?? '',
|
||||
arbiterDeadlockThreshold: readInteger('ARBITER_DEADLOCK_THRESHOLD', 2),
|
||||
maxRoundTrips,
|
||||
},
|
||||
models: {
|
||||
owner: buildRoleModelConfig('OWNER'),
|
||||
reviewer: buildRoleModelConfig('REVIEWER'),
|
||||
arbiter: buildRoleModelConfig('ARBITER'),
|
||||
},
|
||||
providers: {
|
||||
claudeDefaultModel: readText('CLAUDE_MODEL') ?? 'claude',
|
||||
codexDefaultModel: readText('CODEX_MODEL') ?? 'codex',
|
||||
},
|
||||
moa: buildMoaConfig(),
|
||||
status: {
|
||||
channelId: readText('STATUS_CHANNEL_ID') ?? '',
|
||||
updateInterval: 10000,
|
||||
usageUpdateInterval: 300000,
|
||||
showRooms: readBooleanUnlessFalse('STATUS_SHOW_ROOMS', true),
|
||||
showRoomDetails: readBooleanUnlessFalse('STATUS_SHOW_ROOM_DETAILS', true),
|
||||
usageDashboardEnabled: readText('USAGE_DASHBOARD') === 'true',
|
||||
timezone:
|
||||
readText('TZ') ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
sessionCommands: {
|
||||
allowedSenders: new Set(
|
||||
(
|
||||
readText('SESSION_COMMAND_ALLOWED_SENDERS') ??
|
||||
readText('SESSION_COMMAND_USER_IDS') ??
|
||||
''
|
||||
)
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
},
|
||||
deltaHandoff: {
|
||||
enabled: readText('DELTA_HANDOFF_ENABLED') === 'true',
|
||||
canaryGroups: new Set(
|
||||
(readText('DELTA_HANDOFF_CANARY_GROUPS') ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
86
src/config/schema.ts
Normal file
86
src/config/schema.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { MoaConfig } from '../moa.js';
|
||||
import type { AgentType } from '../types.js';
|
||||
|
||||
export interface RoleModelConfig {
|
||||
model?: string;
|
||||
effort?: string;
|
||||
fallbackEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
assistant: {
|
||||
name: string;
|
||||
hasOwnNumber: boolean;
|
||||
slug: string;
|
||||
triggerPattern: RegExp;
|
||||
};
|
||||
service: {
|
||||
id: string;
|
||||
sessionScope: string;
|
||||
claudeId: string;
|
||||
codexMainId: string;
|
||||
codexReviewId: string;
|
||||
};
|
||||
paths: {
|
||||
projectRoot: string;
|
||||
homeDir: string;
|
||||
senderAllowlistPath: string;
|
||||
storeDir: string;
|
||||
groupsDir: string;
|
||||
dataDir: string;
|
||||
cacheDir: string;
|
||||
};
|
||||
runtime: {
|
||||
pollInterval: number;
|
||||
schedulerPollInterval: number;
|
||||
failoverMinDurationMs: number;
|
||||
agentTimeout: number;
|
||||
agentMaxOutputSize: number;
|
||||
ipcPollInterval: number;
|
||||
idleTimeout: number;
|
||||
maxConcurrentAgents: number;
|
||||
recoveryConcurrentAgents: number;
|
||||
recoveryStaggerMs: number;
|
||||
recoveryDurationMs: number;
|
||||
};
|
||||
logging: {
|
||||
level: string;
|
||||
isTestEnv: boolean;
|
||||
};
|
||||
paired: {
|
||||
ownerAgentType: AgentType;
|
||||
reviewerAgentType: AgentType;
|
||||
reviewerServiceIdForType: string;
|
||||
arbiterAgentType?: AgentType;
|
||||
arbiterServiceId: string | null;
|
||||
agentLanguage: string;
|
||||
arbiterDeadlockThreshold: number;
|
||||
maxRoundTrips: number;
|
||||
};
|
||||
models: {
|
||||
owner: RoleModelConfig;
|
||||
reviewer: RoleModelConfig;
|
||||
arbiter: RoleModelConfig;
|
||||
};
|
||||
providers: {
|
||||
claudeDefaultModel: string;
|
||||
codexDefaultModel: string;
|
||||
};
|
||||
moa: MoaConfig;
|
||||
status: {
|
||||
channelId: string;
|
||||
updateInterval: number;
|
||||
usageUpdateInterval: number;
|
||||
showRooms: boolean;
|
||||
showRoomDetails: boolean;
|
||||
usageDashboardEnabled: boolean;
|
||||
timezone: string;
|
||||
};
|
||||
sessionCommands: {
|
||||
allowedSenders: Set<string>;
|
||||
};
|
||||
deltaHandoff: {
|
||||
enabled: boolean;
|
||||
canaryGroups: Set<string>;
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getExplicitRoomMode,
|
||||
getLatestMessageSeqAtOrBefore,
|
||||
getLatestPairedTaskForChat,
|
||||
getLastRespondingAgentType,
|
||||
getMessagesSinceSeq,
|
||||
getNewMessagesBySeq,
|
||||
getOpenWorkItem,
|
||||
@@ -164,6 +165,31 @@ describe('storeMessage', () => {
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('detects the most recent bot responder agent type', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
storeMessage({
|
||||
id: 'bot-1',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'claude-main',
|
||||
sender_name: 'Claude',
|
||||
content: 'first bot reply',
|
||||
timestamp: '2024-01-01T00:00:01.000Z',
|
||||
is_bot_message: true,
|
||||
});
|
||||
storeMessage({
|
||||
id: 'bot-2',
|
||||
chat_jid: 'group@g.us',
|
||||
sender: 'codex-review',
|
||||
sender_name: 'Codex',
|
||||
content: 'second bot reply',
|
||||
timestamp: '2024-01-01T00:00:02.000Z',
|
||||
is_bot_message: true,
|
||||
});
|
||||
|
||||
expect(getLastRespondingAgentType('group@g.us')).toBe('codex');
|
||||
});
|
||||
|
||||
it('upserts on duplicate id+chat_jid', () => {
|
||||
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
|
||||
|
||||
@@ -361,6 +387,39 @@ describe('session accessors', () => {
|
||||
expect(getSession('group-a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates legacy sessions table rows into the composite primary key schema', () => {
|
||||
const tempDir = fs.mkdtempSync('/tmp/ejclaw-session-schema-migration-');
|
||||
const dbPath = path.join(tempDir, 'messages.db');
|
||||
const legacyDb = new Database(dbPath);
|
||||
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE sessions (
|
||||
group_folder TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
legacyDb
|
||||
.prepare(
|
||||
`INSERT INTO sessions (group_folder, session_id)
|
||||
VALUES (?, ?)`,
|
||||
)
|
||||
.run('group-legacy-schema', 'legacy-schema-session-123');
|
||||
legacyDb.close();
|
||||
|
||||
_initTestDatabaseFromFile(dbPath);
|
||||
|
||||
expect(getSession('group-legacy-schema', 'claude-code')).toBe(
|
||||
'legacy-schema-session-123',
|
||||
);
|
||||
|
||||
const migratedDb = new Database(dbPath, { readonly: true });
|
||||
const sessionColumns = migratedDb
|
||||
.prepare(`PRAGMA table_info(sessions)`)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(sessionColumns.some((col) => col.name === 'agent_type')).toBe(true);
|
||||
migratedDb.close();
|
||||
});
|
||||
|
||||
it('backfills legacy service-scoped sessions into canonical agent sessions during init', () => {
|
||||
const tempDir = fs.mkdtempSync('/tmp/ejclaw-session-backfill-');
|
||||
const dbPath = path.join(tempDir, 'messages.db');
|
||||
@@ -1522,6 +1581,38 @@ describe('room assignment writes', () => {
|
||||
agentType: 'codex',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates room_settings-backed metadata across tribunal projection rows', () => {
|
||||
assignRoom('dc:projection-room', {
|
||||
name: 'Projection Room',
|
||||
roomMode: 'tribunal',
|
||||
ownerAgentType: 'codex',
|
||||
folder: 'projection-room',
|
||||
});
|
||||
|
||||
updateRegisteredGroupName('dc:projection-room', 'Projection Room Renamed');
|
||||
|
||||
expect(getStoredRoomSettings('dc:projection-room')).toMatchObject({
|
||||
chatJid: 'dc:projection-room',
|
||||
name: 'Projection Room Renamed',
|
||||
roomMode: 'tribunal',
|
||||
ownerAgentType: 'codex',
|
||||
});
|
||||
expect(
|
||||
getAllRegisteredGroups('claude-code')['dc:projection-room'],
|
||||
).toMatchObject({
|
||||
name: 'Projection Room Renamed',
|
||||
folder: 'projection-room',
|
||||
agentType: 'claude-code',
|
||||
});
|
||||
expect(getAllRegisteredGroups('codex')['dc:projection-room']).toMatchObject(
|
||||
{
|
||||
name: 'Projection Room Renamed',
|
||||
folder: 'projection-room',
|
||||
agentType: 'codex',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paired room registration', () => {
|
||||
|
||||
261
src/db/base-schema.ts
Normal file
261
src/db/base-schema.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
export function applyBaseSchema(database: Database): void {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_message_time TEXT,
|
||||
channel TEXT,
|
||||
is_group INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT,
|
||||
chat_jid TEXT,
|
||||
sender TEXT,
|
||||
sender_name TEXT,
|
||||
content TEXT,
|
||||
timestamp TEXT,
|
||||
seq INTEGER,
|
||||
is_from_me INTEGER,
|
||||
is_bot_message INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (id, chat_jid),
|
||||
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
|
||||
CREATE TABLE IF NOT EXISTS message_sequence (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_folder TEXT NOT NULL,
|
||||
chat_jid TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
delivery_role TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'produced',
|
||||
start_seq INTEGER,
|
||||
end_seq INTEGER,
|
||||
result_payload TEXT NOT NULL,
|
||||
delivery_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
delivery_message_id TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
delivered_at TEXT,
|
||||
CHECK (status IN ('produced', 'delivery_retry', 'delivered')),
|
||||
CHECK (delivery_role IN ('owner', 'reviewer', 'arbiter') OR delivery_role IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items(status, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent ON work_items(chat_jid, agent_type, delivery_role, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
|
||||
ON work_items(chat_jid, agent_type, IFNULL(delivery_role, ''))
|
||||
WHERE status IN ('produced', 'delivery_retry');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
group_folder TEXT NOT NULL,
|
||||
chat_jid TEXT NOT NULL,
|
||||
agent_type TEXT,
|
||||
ci_provider TEXT,
|
||||
ci_metadata TEXT,
|
||||
max_duration_ms INTEGER,
|
||||
status_message_id TEXT,
|
||||
status_started_at TEXT,
|
||||
prompt TEXT NOT NULL,
|
||||
schedule_type TEXT NOT NULL,
|
||||
schedule_value TEXT NOT NULL,
|
||||
next_run TEXT,
|
||||
last_run TEXT,
|
||||
last_result TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run);
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_run_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
run_at TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS router_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
group_folder TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
session_id TEXT NOT NULL,
|
||||
PRIMARY KEY (group_folder, agent_type)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
agent_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type),
|
||||
UNIQUE (folder, agent_type)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS paired_projects (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
group_folder TEXT NOT NULL,
|
||||
canonical_work_dir TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS paired_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
owner_agent_type TEXT,
|
||||
reviewer_agent_type TEXT,
|
||||
arbiter_agent_type TEXT,
|
||||
title TEXT,
|
||||
source_ref TEXT,
|
||||
plan_notes TEXT,
|
||||
review_requested_at TEXT,
|
||||
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
arbiter_verdict TEXT,
|
||||
arbiter_requested_at TEXT,
|
||||
completion_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
||||
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
||||
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||
ON paired_tasks(chat_jid, status, updated_at);
|
||||
CREATE TABLE IF NOT EXISTS paired_workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
workspace_dir TEXT NOT NULL,
|
||||
snapshot_source_dir TEXT,
|
||||
snapshot_ref TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ready',
|
||||
snapshot_refreshed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (role IN ('owner', 'reviewer')),
|
||||
CHECK (status IN ('ready', 'stale'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
|
||||
ON paired_workspaces(task_id, role);
|
||||
CREATE TABLE IF NOT EXISTS paired_turn_outputs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
turn_number INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
output_text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(task_id, turn_number, role)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_turn_outputs_task
|
||||
ON paired_turn_outputs(task_id, turn_number);
|
||||
CREATE TABLE IF NOT EXISTS channel_owner (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
owner_agent_type TEXT,
|
||||
reviewer_agent_type TEXT,
|
||||
arbiter_agent_type TEXT,
|
||||
activated_at TEXT,
|
||||
reason TEXT,
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
||||
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
||||
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS room_settings (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
room_mode TEXT NOT NULL,
|
||||
mode_source TEXT NOT NULL DEFAULT 'explicit',
|
||||
name TEXT,
|
||||
folder TEXT,
|
||||
trigger_pattern TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
owner_agent_type TEXT,
|
||||
work_dir TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (room_mode IN ('single', 'tribunal')),
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS service_handoffs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
source_role TEXT,
|
||||
source_agent_type TEXT,
|
||||
target_role TEXT,
|
||||
target_agent_type TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
start_seq INTEGER,
|
||||
end_seq INTEGER,
|
||||
reason TEXT,
|
||||
intended_role TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
claimed_at TEXT,
|
||||
completed_at TEXT,
|
||||
last_error TEXT,
|
||||
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
|
||||
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL),
|
||||
CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
|
||||
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
|
||||
ON service_handoffs(status, target_role, target_agent_type, created_at);
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
scope_kind TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
keywords_json TEXT NOT NULL DEFAULT '[]',
|
||||
memory_kind TEXT,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_ref TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
archived_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_scope
|
||||
ON memories(scope_kind, scope_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_active
|
||||
ON memories(scope_kind, scope_key, archived_at, created_at);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||
content,
|
||||
keywords,
|
||||
content='',
|
||||
tokenize='unicode61'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
||||
INSERT INTO memories_fts(rowid, content, keywords)
|
||||
VALUES (new.id, new.content, new.keywords_json);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, content, keywords)
|
||||
VALUES ('delete', old.id, old.content, old.keywords_json);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, content, keywords)
|
||||
VALUES ('delete', old.id, old.content, old.keywords_json);
|
||||
INSERT INTO memories_fts(rowid, content, keywords)
|
||||
VALUES (new.id, new.content, new.keywords_json);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
107
src/db/bootstrap.ts
Normal file
107
src/db/bootstrap.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { RegisteredGroup } from '../types.js';
|
||||
import { readJsonFile } from '../utils.js';
|
||||
import { applyBaseSchema } from './base-schema.js';
|
||||
import { type SchemaMigrationHooks, applySchemaMigrations } from './schema.js';
|
||||
|
||||
export interface JsonStateMigrationHooks {
|
||||
setRouterState(key: string, value: string): void;
|
||||
setSession(groupFolder: string, sessionId: string): void;
|
||||
writeLegacyRegisteredGroupAndSyncRoomSettings(
|
||||
jid: string,
|
||||
group: RegisteredGroup,
|
||||
): void;
|
||||
}
|
||||
|
||||
export function initializeDatabaseSchema(
|
||||
database: Database,
|
||||
hooks: SchemaMigrationHooks,
|
||||
): void {
|
||||
applyBaseSchema(database);
|
||||
applySchemaMigrations(database, {
|
||||
assistantName: ASSISTANT_NAME,
|
||||
hooks,
|
||||
});
|
||||
}
|
||||
|
||||
export function openPersistentDatabase(): Database {
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
|
||||
const database = new Database(dbPath);
|
||||
database.exec('PRAGMA journal_mode = WAL');
|
||||
database.exec('PRAGMA busy_timeout = 5000');
|
||||
return database;
|
||||
}
|
||||
|
||||
export function openInMemoryDatabase(): Database {
|
||||
return new Database(':memory:');
|
||||
}
|
||||
|
||||
export function openDatabaseFromFile(dbPath: string): Database {
|
||||
return new Database(dbPath);
|
||||
}
|
||||
|
||||
export function migrateJsonStateFromFiles(
|
||||
hooks: JsonStateMigrationHooks,
|
||||
): void {
|
||||
const migrateFile = (filename: string) => {
|
||||
const filePath = path.join(DATA_DIR, filename);
|
||||
const data = readJsonFile(filePath);
|
||||
if (data === null) return null;
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.migrated`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const routerState = migrateFile('router_state.json') as {
|
||||
last_timestamp?: string;
|
||||
last_agent_timestamp?: Record<string, string>;
|
||||
} | null;
|
||||
if (routerState) {
|
||||
if (routerState.last_timestamp) {
|
||||
hooks.setRouterState('last_timestamp', routerState.last_timestamp);
|
||||
}
|
||||
if (routerState.last_agent_timestamp) {
|
||||
hooks.setRouterState(
|
||||
'last_agent_timestamp',
|
||||
JSON.stringify(routerState.last_agent_timestamp),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = migrateFile('sessions.json') as Record<
|
||||
string,
|
||||
string
|
||||
> | null;
|
||||
if (sessions) {
|
||||
for (const [folder, sessionId] of Object.entries(sessions)) {
|
||||
hooks.setSession(folder, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = migrateFile('registered_groups.json') as Record<
|
||||
string,
|
||||
RegisteredGroup
|
||||
> | null;
|
||||
if (groups) {
|
||||
for (const [jid, group] of Object.entries(groups)) {
|
||||
try {
|
||||
hooks.writeLegacyRegisteredGroupAndSyncRoomSettings(jid, group);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ jid, folder: group.folder, err },
|
||||
'Skipping migrated registered group with invalid folder',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
767
src/db/legacy-rebuilds.ts
Normal file
767
src/db/legacy-rebuilds.ts
Normal file
@@ -0,0 +1,767 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import {
|
||||
ARBITER_AGENT_TYPE,
|
||||
OWNER_AGENT_TYPE,
|
||||
REVIEWER_AGENT_TYPE,
|
||||
SERVICE_SESSION_SCOPE,
|
||||
} from '../config.js';
|
||||
import {
|
||||
collectRegisteredAgentTypes,
|
||||
collectRegisteredAgentTypesForFolder,
|
||||
collectRoomRegistrationSnapshot,
|
||||
getStoredRoomSettingsRowFromDatabase,
|
||||
inferOwnerAgentTypeFromRegisteredAgentTypes,
|
||||
inferRoomModeFromRegisteredAgentTypes,
|
||||
insertStoredRoomSettings,
|
||||
normalizeStoredAgentType,
|
||||
updateStoredRoomMetadata,
|
||||
} from './room-registration.js';
|
||||
import { tableHasColumn } from './schema.js';
|
||||
import {
|
||||
inferAgentTypeFromServiceShadow,
|
||||
resolveRoleServiceShadow,
|
||||
} from '../role-service-shadow.js';
|
||||
import type { AgentType, PairedRoomRole } from '../types.js';
|
||||
|
||||
interface StablePairedTaskRowInput {
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
owner_agent_type?: string | null;
|
||||
}
|
||||
|
||||
interface StableLeaseRoleRowInput {
|
||||
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;
|
||||
}
|
||||
|
||||
interface WorkItemServiceShadowRow {
|
||||
id: number;
|
||||
agent_type: string;
|
||||
service_id: string;
|
||||
delivery_role: PairedRoomRole | null;
|
||||
}
|
||||
|
||||
interface LegacyServiceHandoffServiceRow {
|
||||
id: number;
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
source_service_id?: string | null;
|
||||
target_service_id?: string | null;
|
||||
source_role: PairedRoomRole | null;
|
||||
source_agent_type?: string | null;
|
||||
target_role: PairedRoomRole | null;
|
||||
target_agent_type?: string | null;
|
||||
}
|
||||
|
||||
interface LegacyPairedTaskServiceRow {
|
||||
id: string;
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
owner_service_id: string;
|
||||
reviewer_service_id: string;
|
||||
owner_agent_type?: string | null;
|
||||
reviewer_agent_type?: string | null;
|
||||
arbiter_agent_type?: string | null;
|
||||
}
|
||||
|
||||
export function backfillStoredRoomSettings(database: Database): void {
|
||||
const rows = database
|
||||
.prepare(`SELECT DISTINCT jid FROM registered_groups`)
|
||||
.all() as Array<{ jid: string }>;
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const tx = database.transaction((registeredRows: Array<{ jid: string }>) => {
|
||||
for (const row of registeredRows) {
|
||||
const existing = getStoredRoomSettingsRowFromDatabase(database, row.jid);
|
||||
const snapshot = collectRoomRegistrationSnapshot(
|
||||
database,
|
||||
row.jid,
|
||||
existing,
|
||||
);
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (existing) {
|
||||
updateStoredRoomMetadata(database, row.jid, snapshot);
|
||||
continue;
|
||||
}
|
||||
|
||||
insertStoredRoomSettings(
|
||||
database,
|
||||
row.jid,
|
||||
inferRoomModeFromRegisteredAgentTypes(
|
||||
collectRegisteredAgentTypes(database, row.jid),
|
||||
),
|
||||
'inferred',
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
});
|
||||
tx(rows);
|
||||
}
|
||||
|
||||
export function resolveStablePairedTaskOwnerAgentType(
|
||||
database: Database,
|
||||
task: StablePairedTaskRowInput,
|
||||
): AgentType | undefined {
|
||||
const persistedOwnerAgentType = normalizeStoredAgentType(
|
||||
task.owner_agent_type,
|
||||
);
|
||||
if (persistedOwnerAgentType) {
|
||||
return persistedOwnerAgentType;
|
||||
}
|
||||
|
||||
const stored = getStoredRoomSettingsRowFromDatabase(database, task.chat_jid);
|
||||
if (stored?.ownerAgentType) {
|
||||
return stored.ownerAgentType;
|
||||
}
|
||||
|
||||
const jidAgentTypes = collectRegisteredAgentTypes(database, task.chat_jid);
|
||||
if (jidAgentTypes.length > 0) {
|
||||
return inferOwnerAgentTypeFromRegisteredAgentTypes(jidAgentTypes);
|
||||
}
|
||||
|
||||
const folderAgentTypes = collectRegisteredAgentTypesForFolder(
|
||||
database,
|
||||
task.group_folder,
|
||||
);
|
||||
if (folderAgentTypes.length > 0) {
|
||||
return inferOwnerAgentTypeFromRegisteredAgentTypes(folderAgentTypes);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveStableReviewerAgentType(
|
||||
ownerAgentType: AgentType | undefined,
|
||||
fallbackReviewerAgentType?: string | null,
|
||||
): AgentType | null {
|
||||
const persistedReviewerAgentType = normalizeStoredAgentType(
|
||||
fallbackReviewerAgentType,
|
||||
);
|
||||
if (persistedReviewerAgentType) {
|
||||
return persistedReviewerAgentType;
|
||||
}
|
||||
|
||||
if (ownerAgentType) {
|
||||
return REVIEWER_AGENT_TYPE !== ownerAgentType
|
||||
? REVIEWER_AGENT_TYPE
|
||||
: ownerAgentType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveStableRoomRoleAgentType(
|
||||
database: Database,
|
||||
input: {
|
||||
chatJid: string;
|
||||
groupFolder: string;
|
||||
role: PairedRoomRole;
|
||||
},
|
||||
): AgentType | null | undefined {
|
||||
if (input.role === 'owner') {
|
||||
return resolveStablePairedTaskOwnerAgentType(database, {
|
||||
chat_jid: input.chatJid,
|
||||
group_folder: input.groupFolder,
|
||||
owner_agent_type: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.role === 'reviewer') {
|
||||
const ownerAgentType = resolveStablePairedTaskOwnerAgentType(database, {
|
||||
chat_jid: input.chatJid,
|
||||
group_folder: input.groupFolder,
|
||||
owner_agent_type: null,
|
||||
});
|
||||
return resolveStableReviewerAgentType(ownerAgentType, null);
|
||||
}
|
||||
|
||||
return ARBITER_AGENT_TYPE ?? null;
|
||||
}
|
||||
|
||||
function resolveStableLeaseOwnerAgentType(
|
||||
database: Database,
|
||||
row: StableLeaseRoleRowInput,
|
||||
): AgentType | undefined {
|
||||
const persisted = normalizeStoredAgentType(row.owner_agent_type);
|
||||
if (persisted) {
|
||||
return persisted;
|
||||
}
|
||||
const stored = getStoredRoomSettingsRowFromDatabase(database, row.chat_jid);
|
||||
if (stored?.ownerAgentType) {
|
||||
return stored.ownerAgentType;
|
||||
}
|
||||
return inferAgentTypeFromServiceShadow(row.owner_service_id);
|
||||
}
|
||||
|
||||
function resolveStableLeaseRoleAgentType(
|
||||
database: Database,
|
||||
row: StableLeaseRoleRowInput,
|
||||
role: PairedRoomRole,
|
||||
): AgentType | null | undefined {
|
||||
if (role === 'owner') {
|
||||
return resolveStableLeaseOwnerAgentType(database, row);
|
||||
}
|
||||
if (role === 'reviewer') {
|
||||
if (row.reviewer_service_id == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
normalizeStoredAgentType(row.reviewer_agent_type) ??
|
||||
inferAgentTypeFromServiceShadow(row.reviewer_service_id) ??
|
||||
resolveStableReviewerAgentType(
|
||||
resolveStableLeaseOwnerAgentType(database, row),
|
||||
null,
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
normalizeStoredAgentType(row.arbiter_agent_type) ??
|
||||
(row.arbiter_service_id
|
||||
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
|
||||
: undefined) ??
|
||||
ARBITER_AGENT_TYPE ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function backfillChannelOwnerRoleMetadata(database: Database): void {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
chat_jid,
|
||||
owner_service_id,
|
||||
reviewer_service_id,
|
||||
arbiter_service_id,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type
|
||||
FROM channel_owner`,
|
||||
)
|
||||
.all() as StableLeaseRoleRowInput[];
|
||||
|
||||
const update = database.prepare(
|
||||
`UPDATE channel_owner
|
||||
SET owner_service_id = ?,
|
||||
reviewer_service_id = ?,
|
||||
arbiter_service_id = ?,
|
||||
owner_agent_type = ?,
|
||||
reviewer_agent_type = ?,
|
||||
arbiter_agent_type = ?
|
||||
WHERE chat_jid = ?`,
|
||||
);
|
||||
|
||||
const tx = database.transaction((leaseRows: StableLeaseRoleRowInput[]) => {
|
||||
for (const row of leaseRows) {
|
||||
const ownerAgentType = resolveStableLeaseRoleAgentType(
|
||||
database,
|
||||
row,
|
||||
'owner',
|
||||
);
|
||||
const reviewerAgentType = resolveStableLeaseRoleAgentType(
|
||||
database,
|
||||
row,
|
||||
'reviewer',
|
||||
);
|
||||
const arbiterAgentType = resolveStableLeaseRoleAgentType(
|
||||
database,
|
||||
row,
|
||||
'arbiter',
|
||||
);
|
||||
|
||||
const ownerServiceId =
|
||||
resolveRoleServiceShadow('owner', ownerAgentType) ??
|
||||
row.owner_service_id ??
|
||||
null;
|
||||
const reviewerServiceId =
|
||||
row.reviewer_service_id == null
|
||||
? null
|
||||
: (resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
|
||||
row.reviewer_service_id);
|
||||
const arbiterServiceId =
|
||||
row.arbiter_service_id == null
|
||||
? null
|
||||
: (resolveRoleServiceShadow('arbiter', arbiterAgentType) ??
|
||||
row.arbiter_service_id);
|
||||
|
||||
if (
|
||||
ownerServiceId === row.owner_service_id &&
|
||||
reviewerServiceId === row.reviewer_service_id &&
|
||||
arbiterServiceId === row.arbiter_service_id &&
|
||||
(ownerAgentType ?? null) === (row.owner_agent_type ?? null) &&
|
||||
(reviewerAgentType ?? null) === (row.reviewer_agent_type ?? null) &&
|
||||
(arbiterAgentType ?? null) === (row.arbiter_agent_type ?? null)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
update.run(
|
||||
ownerServiceId,
|
||||
reviewerServiceId,
|
||||
arbiterServiceId,
|
||||
ownerAgentType ?? null,
|
||||
reviewerAgentType ?? null,
|
||||
arbiterAgentType ?? null,
|
||||
row.chat_jid,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tx(rows);
|
||||
}
|
||||
|
||||
export function resolveWorkItemServiceShadow(
|
||||
agentType: AgentType,
|
||||
deliveryRole?: PairedRoomRole | null,
|
||||
): string {
|
||||
return (
|
||||
resolveRoleServiceShadow(deliveryRole ?? 'owner', agentType) ??
|
||||
SERVICE_SESSION_SCOPE
|
||||
);
|
||||
}
|
||||
|
||||
export function backfillWorkItemServiceShadows(database: Database): void {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT id, agent_type, service_id, delivery_role
|
||||
FROM work_items`,
|
||||
)
|
||||
.all() as WorkItemServiceShadowRow[];
|
||||
|
||||
const update = database.prepare(
|
||||
`UPDATE work_items
|
||||
SET service_id = ?
|
||||
WHERE id = ?`,
|
||||
);
|
||||
|
||||
const tx = database.transaction(
|
||||
(workItemRows: WorkItemServiceShadowRow[]) => {
|
||||
for (const row of workItemRows) {
|
||||
const agentType = normalizeStoredAgentType(row.agent_type);
|
||||
if (!agentType) {
|
||||
continue;
|
||||
}
|
||||
const normalizedServiceId = resolveWorkItemServiceShadow(
|
||||
agentType,
|
||||
row.delivery_role,
|
||||
);
|
||||
if (normalizedServiceId === row.service_id) {
|
||||
continue;
|
||||
}
|
||||
update.run(normalizedServiceId, row.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
tx(rows);
|
||||
}
|
||||
|
||||
export function backfillServiceHandoffServiceShadows(database: Database): void {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
chat_jid,
|
||||
group_folder,
|
||||
source_service_id,
|
||||
target_service_id,
|
||||
source_role,
|
||||
source_agent_type,
|
||||
target_role,
|
||||
target_agent_type
|
||||
FROM service_handoffs`,
|
||||
)
|
||||
.all() as LegacyServiceHandoffServiceRow[];
|
||||
|
||||
const update = database.prepare(
|
||||
`UPDATE service_handoffs
|
||||
SET source_service_id = ?,
|
||||
target_service_id = ?,
|
||||
source_agent_type = ?,
|
||||
target_agent_type = ?
|
||||
WHERE id = ?`,
|
||||
);
|
||||
|
||||
const tx = database.transaction(
|
||||
(handoffRows: Array<LegacyServiceHandoffServiceRow>) => {
|
||||
for (const row of handoffRows) {
|
||||
const sourceAgentType =
|
||||
normalizeStoredAgentType(row.source_agent_type) ??
|
||||
(row.source_role
|
||||
? resolveStableRoomRoleAgentType(database, {
|
||||
chatJid: row.chat_jid,
|
||||
groupFolder: row.group_folder,
|
||||
role: row.source_role,
|
||||
})
|
||||
: null) ??
|
||||
inferAgentTypeFromServiceShadow(row.source_service_id);
|
||||
const targetAgentType =
|
||||
normalizeStoredAgentType(row.target_agent_type) ??
|
||||
(row.target_role
|
||||
? resolveStableRoomRoleAgentType(database, {
|
||||
chatJid: row.chat_jid,
|
||||
groupFolder: row.group_folder,
|
||||
role: row.target_role,
|
||||
})
|
||||
: null);
|
||||
|
||||
const normalizedSourceServiceId =
|
||||
row.source_role != null
|
||||
? (resolveRoleServiceShadow(row.source_role, sourceAgentType) ??
|
||||
row.source_service_id)
|
||||
: row.source_service_id;
|
||||
const normalizedTargetServiceId =
|
||||
row.target_role != null
|
||||
? (resolveRoleServiceShadow(row.target_role, targetAgentType) ??
|
||||
row.target_service_id)
|
||||
: row.target_service_id;
|
||||
|
||||
if (
|
||||
normalizedSourceServiceId === row.source_service_id &&
|
||||
normalizedTargetServiceId === row.target_service_id &&
|
||||
(sourceAgentType ?? null) === (row.source_agent_type ?? null) &&
|
||||
(targetAgentType ?? null) === (row.target_agent_type ?? null)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
update.run(
|
||||
normalizedSourceServiceId ?? SERVICE_SESSION_SCOPE,
|
||||
normalizedTargetServiceId ?? SERVICE_SESSION_SCOPE,
|
||||
sourceAgentType ?? null,
|
||||
targetAgentType ?? null,
|
||||
row.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
tx(rows);
|
||||
}
|
||||
|
||||
export function backfillPairedTaskRoleMetadata(database: Database): void {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
chat_jid,
|
||||
group_folder,
|
||||
owner_service_id,
|
||||
reviewer_service_id,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type
|
||||
FROM paired_tasks`,
|
||||
)
|
||||
.all() as LegacyPairedTaskServiceRow[];
|
||||
|
||||
const tx = database.transaction((taskRows: LegacyPairedTaskServiceRow[]) => {
|
||||
const update = database.prepare(
|
||||
`UPDATE paired_tasks
|
||||
SET owner_service_id = ?,
|
||||
reviewer_service_id = ?,
|
||||
owner_agent_type = ?,
|
||||
reviewer_agent_type = ?,
|
||||
arbiter_agent_type = ?
|
||||
WHERE id = ?`,
|
||||
);
|
||||
|
||||
for (const row of taskRows) {
|
||||
const ownerAgentType = resolveStablePairedTaskOwnerAgentType(
|
||||
database,
|
||||
row,
|
||||
);
|
||||
const reviewerAgentType = resolveStableReviewerAgentType(
|
||||
ownerAgentType,
|
||||
row.reviewer_agent_type ?? null,
|
||||
);
|
||||
const arbiterAgentType =
|
||||
normalizeStoredAgentType(row.arbiter_agent_type) ??
|
||||
ARBITER_AGENT_TYPE ??
|
||||
null;
|
||||
|
||||
const ownerServiceId =
|
||||
resolveRoleServiceShadow('owner', ownerAgentType) ??
|
||||
row.owner_service_id;
|
||||
const reviewerServiceId =
|
||||
resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
|
||||
row.reviewer_service_id;
|
||||
|
||||
update.run(
|
||||
ownerServiceId,
|
||||
reviewerServiceId,
|
||||
ownerAgentType ?? null,
|
||||
reviewerAgentType ?? null,
|
||||
arbiterAgentType,
|
||||
row.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tx(rows);
|
||||
}
|
||||
|
||||
export function rebuildWorkItemsCanonicalSchema(database: Database): void {
|
||||
if (!tableHasColumn(database, 'work_items', 'service_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE work_items_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_folder TEXT NOT NULL,
|
||||
chat_jid TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
delivery_role TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'produced',
|
||||
start_seq INTEGER,
|
||||
end_seq INTEGER,
|
||||
result_payload TEXT NOT NULL,
|
||||
delivery_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
delivery_message_id TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
delivered_at TEXT,
|
||||
CHECK (status IN ('produced', 'delivery_retry', 'delivered')),
|
||||
CHECK (delivery_role IN ('owner', 'reviewer', 'arbiter') OR delivery_role IS NULL)
|
||||
);
|
||||
INSERT INTO work_items_new (
|
||||
id,
|
||||
group_folder,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
delivery_role,
|
||||
status,
|
||||
start_seq,
|
||||
end_seq,
|
||||
result_payload,
|
||||
delivery_attempts,
|
||||
delivery_message_id,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
delivered_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
group_folder,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
delivery_role,
|
||||
status,
|
||||
start_seq,
|
||||
end_seq,
|
||||
result_payload,
|
||||
delivery_attempts,
|
||||
delivery_message_id,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
delivered_at
|
||||
FROM work_items;
|
||||
DROP TABLE work_items;
|
||||
ALTER TABLE work_items_new RENAME TO work_items;
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_status
|
||||
ON work_items(status, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent
|
||||
ON work_items(chat_jid, agent_type, delivery_role, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
|
||||
ON work_items(chat_jid, agent_type, IFNULL(delivery_role, ''))
|
||||
WHERE status IN ('produced', 'delivery_retry');
|
||||
`);
|
||||
}
|
||||
|
||||
export function rebuildChannelOwnerCanonicalSchema(database: Database): void {
|
||||
if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE channel_owner_new (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
owner_agent_type TEXT,
|
||||
reviewer_agent_type TEXT,
|
||||
arbiter_agent_type TEXT,
|
||||
activated_at TEXT,
|
||||
reason TEXT,
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
||||
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
||||
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
||||
);
|
||||
INSERT INTO channel_owner_new (
|
||||
chat_jid,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type,
|
||||
activated_at,
|
||||
reason
|
||||
)
|
||||
SELECT
|
||||
chat_jid,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type,
|
||||
activated_at,
|
||||
reason
|
||||
FROM channel_owner;
|
||||
DROP TABLE channel_owner;
|
||||
ALTER TABLE channel_owner_new RENAME TO channel_owner;
|
||||
`);
|
||||
}
|
||||
|
||||
export function rebuildPairedTasksCanonicalSchema(database: Database): void {
|
||||
if (!tableHasColumn(database, 'paired_tasks', 'owner_service_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE paired_tasks_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
owner_agent_type TEXT,
|
||||
reviewer_agent_type TEXT,
|
||||
arbiter_agent_type TEXT,
|
||||
title TEXT,
|
||||
source_ref TEXT,
|
||||
plan_notes TEXT,
|
||||
review_requested_at TEXT,
|
||||
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
arbiter_verdict TEXT,
|
||||
arbiter_requested_at TEXT,
|
||||
completion_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
||||
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
||||
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
||||
);
|
||||
INSERT INTO paired_tasks_new (
|
||||
id,
|
||||
chat_jid,
|
||||
group_folder,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type,
|
||||
title,
|
||||
source_ref,
|
||||
plan_notes,
|
||||
review_requested_at,
|
||||
round_trip_count,
|
||||
status,
|
||||
arbiter_verdict,
|
||||
arbiter_requested_at,
|
||||
completion_reason,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
chat_jid,
|
||||
group_folder,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type,
|
||||
title,
|
||||
source_ref,
|
||||
plan_notes,
|
||||
review_requested_at,
|
||||
round_trip_count,
|
||||
status,
|
||||
arbiter_verdict,
|
||||
arbiter_requested_at,
|
||||
completion_reason,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM paired_tasks;
|
||||
DROP TABLE paired_tasks;
|
||||
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||
ON paired_tasks(chat_jid, status, updated_at);
|
||||
`);
|
||||
}
|
||||
|
||||
export function rebuildServiceHandoffsCanonicalSchema(
|
||||
database: Database,
|
||||
): void {
|
||||
if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE service_handoffs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
source_role TEXT,
|
||||
source_agent_type TEXT,
|
||||
target_role TEXT,
|
||||
target_agent_type TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
start_seq INTEGER,
|
||||
end_seq INTEGER,
|
||||
reason TEXT,
|
||||
intended_role TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
claimed_at TEXT,
|
||||
completed_at TEXT,
|
||||
last_error TEXT,
|
||||
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
|
||||
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL),
|
||||
CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
|
||||
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL),
|
||||
CHECK (source_agent_type IN ('claude-code', 'codex') OR source_agent_type IS NULL)
|
||||
);
|
||||
INSERT INTO service_handoffs_new (
|
||||
id,
|
||||
chat_jid,
|
||||
group_folder,
|
||||
source_role,
|
||||
source_agent_type,
|
||||
target_role,
|
||||
target_agent_type,
|
||||
prompt,
|
||||
status,
|
||||
start_seq,
|
||||
end_seq,
|
||||
reason,
|
||||
intended_role,
|
||||
created_at,
|
||||
claimed_at,
|
||||
completed_at,
|
||||
last_error
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
chat_jid,
|
||||
group_folder,
|
||||
source_role,
|
||||
source_agent_type,
|
||||
target_role,
|
||||
target_agent_type,
|
||||
prompt,
|
||||
status,
|
||||
start_seq,
|
||||
end_seq,
|
||||
reason,
|
||||
intended_role,
|
||||
created_at,
|
||||
claimed_at,
|
||||
completed_at,
|
||||
last_error
|
||||
FROM service_handoffs;
|
||||
DROP TABLE service_handoffs;
|
||||
ALTER TABLE service_handoffs_new RENAME TO service_handoffs;
|
||||
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
|
||||
ON service_handoffs(status, target_role, target_agent_type, created_at);
|
||||
`);
|
||||
}
|
||||
336
src/db/memories.ts
Normal file
336
src/db/memories.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export type MemoryScopeKind = 'room' | 'user' | 'project' | 'global';
|
||||
export type MemorySourceKind = 'compact' | 'explicit' | 'import' | 'system';
|
||||
|
||||
export interface MemoryRecord {
|
||||
id: number;
|
||||
scopeKind: MemoryScopeKind;
|
||||
scopeKey: string;
|
||||
content: string;
|
||||
keywords: string[];
|
||||
memoryKind: string | null;
|
||||
sourceKind: MemorySourceKind;
|
||||
sourceRef: string | null;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RecallMemoryQuery {
|
||||
scopeKind: MemoryScopeKind;
|
||||
scopeKey: string;
|
||||
text?: string;
|
||||
keywords?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface MemoryDatabaseRow {
|
||||
id: number;
|
||||
scope_kind: string;
|
||||
scope_key: string;
|
||||
content: string;
|
||||
keywords_json: string;
|
||||
memory_kind: string | null;
|
||||
source_kind: string;
|
||||
source_ref: string | null;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
archived_at: string | null;
|
||||
}
|
||||
|
||||
const MEMORY_SCOPE_LIMITS: Record<MemoryScopeKind, number> = {
|
||||
room: 300,
|
||||
user: 100,
|
||||
project: 200,
|
||||
global: 100,
|
||||
};
|
||||
const COMPACT_MEMORY_TTL_DAYS = 30;
|
||||
|
||||
function normalizeMemoryKeywords(keywords?: string[]): string[] {
|
||||
if (!Array.isArray(keywords)) return [];
|
||||
return [
|
||||
...new Set(
|
||||
keywords.map((keyword) => keyword.trim().toLowerCase()).filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function parseMemoryKeywords(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return normalizeMemoryKeywords(Array.isArray(parsed) ? parsed : []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mapMemoryRow(row: MemoryDatabaseRow): MemoryRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
scopeKind: row.scope_kind as MemoryScopeKind,
|
||||
scopeKey: row.scope_key,
|
||||
content: row.content,
|
||||
keywords: parseMemoryKeywords(row.keywords_json),
|
||||
memoryKind: row.memory_kind,
|
||||
sourceKind: row.source_kind as MemorySourceKind,
|
||||
sourceRef: row.source_ref,
|
||||
createdAt: row.created_at,
|
||||
lastUsedAt: row.last_used_at,
|
||||
archivedAt: row.archived_at,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMemoryFtsQuery(query: RecallMemoryQuery): string | null {
|
||||
const tokens = normalizeMemoryKeywords([
|
||||
...(query.text?.match(/[\p{L}\p{N}_:-]+/gu) ?? []),
|
||||
...(query.keywords ?? []),
|
||||
]);
|
||||
if (tokens.length === 0) return null;
|
||||
return tokens.map((token) => `"${token.replaceAll('"', '""')}"`).join(' OR ');
|
||||
}
|
||||
|
||||
function getMemoryRowsForScope(
|
||||
database: Database,
|
||||
scopeKind: MemoryScopeKind,
|
||||
scopeKey: string,
|
||||
): MemoryDatabaseRow[] {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM memories
|
||||
WHERE scope_kind = ?
|
||||
AND scope_key = ?
|
||||
AND archived_at IS NULL
|
||||
ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC`,
|
||||
)
|
||||
.all(scopeKind, scopeKey) as MemoryDatabaseRow[];
|
||||
}
|
||||
|
||||
function queryFtsRowOrder(
|
||||
database: Database,
|
||||
query: RecallMemoryQuery,
|
||||
): Map<number, number> {
|
||||
const ftsQuery = buildMemoryFtsQuery(query);
|
||||
if (!ftsQuery) return new Map();
|
||||
try {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT memories.id AS id
|
||||
FROM memories_fts
|
||||
JOIN memories ON memories.id = memories_fts.rowid
|
||||
WHERE memories_fts MATCH ?
|
||||
AND memories.scope_kind = ?
|
||||
AND memories.scope_key = ?
|
||||
AND memories.archived_at IS NULL
|
||||
ORDER BY bm25(memories_fts), memories.created_at DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
ftsQuery,
|
||||
query.scopeKind,
|
||||
query.scopeKey,
|
||||
Math.max(25, (query.limit ?? 10) * 8),
|
||||
) as Array<{ id: number }>;
|
||||
return new Map(rows.map((row, index) => [row.id, rows.length - index]));
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ query, error },
|
||||
'Memory FTS query failed; falling back to scope-only recall',
|
||||
);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
export function touchMemoriesInDatabase(
|
||||
database: Database,
|
||||
ids: number[],
|
||||
): void {
|
||||
const uniqueIds = [
|
||||
...new Set(ids.filter((id) => Number.isInteger(id) && id > 0)),
|
||||
];
|
||||
if (uniqueIds.length === 0) return;
|
||||
const now = new Date().toISOString();
|
||||
const stmt = database.prepare(
|
||||
`UPDATE memories
|
||||
SET last_used_at = ?
|
||||
WHERE id = ?`,
|
||||
);
|
||||
const tx = database.transaction(() => {
|
||||
for (const id of uniqueIds) stmt.run(now, id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
export function archiveMemoryInDatabase(database: Database, id: number): void {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE memories
|
||||
SET archived_at = COALESCE(archived_at, ?)
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
function buildCompactMemoryExpiryCutoff(nowIso: string): string {
|
||||
return new Date(
|
||||
new Date(nowIso).getTime() - COMPACT_MEMORY_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
export function expireStaleMemoriesInDatabase(
|
||||
database: Database,
|
||||
args?: {
|
||||
scopeKind?: MemoryScopeKind;
|
||||
scopeKey?: string;
|
||||
now?: string;
|
||||
},
|
||||
): number {
|
||||
const nowIso = args?.now ?? new Date().toISOString();
|
||||
const cutoff = buildCompactMemoryExpiryCutoff(nowIso);
|
||||
const scopeClause =
|
||||
args?.scopeKind && args?.scopeKey
|
||||
? 'AND scope_kind = ? AND scope_key = ?'
|
||||
: '';
|
||||
const stmt = database.prepare(
|
||||
`UPDATE memories
|
||||
SET archived_at = COALESCE(archived_at, ?)
|
||||
WHERE archived_at IS NULL
|
||||
AND source_kind = 'compact'
|
||||
AND COALESCE(last_used_at, created_at) < ?
|
||||
${scopeClause}`,
|
||||
);
|
||||
const result = (
|
||||
args?.scopeKind && args?.scopeKey
|
||||
? stmt.run(nowIso, cutoff, args.scopeKind, args.scopeKey)
|
||||
: stmt.run(nowIso, cutoff)
|
||||
) as { changes?: number };
|
||||
return result.changes ?? 0;
|
||||
}
|
||||
|
||||
export function enforceMemoryBoundsInDatabase(
|
||||
database: Database,
|
||||
scopeKind: MemoryScopeKind,
|
||||
scopeKey: string,
|
||||
): void {
|
||||
const limit = MEMORY_SCOPE_LIMITS[scopeKind];
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT id
|
||||
FROM memories
|
||||
WHERE scope_kind = ?
|
||||
AND scope_key = ?
|
||||
AND archived_at IS NULL
|
||||
ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC
|
||||
LIMIT -1 OFFSET ?`,
|
||||
)
|
||||
.all(scopeKind, scopeKey, limit) as Array<{ id: number }>;
|
||||
if (rows.length === 0) return;
|
||||
const now = new Date().toISOString();
|
||||
const stmt = database.prepare(
|
||||
`UPDATE memories
|
||||
SET archived_at = ?
|
||||
WHERE id = ?`,
|
||||
);
|
||||
const tx = database.transaction(() => {
|
||||
for (const row of rows) stmt.run(now, row.id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
export function rememberMemoryInDatabase(
|
||||
database: Database,
|
||||
input: {
|
||||
scopeKind: MemoryScopeKind;
|
||||
scopeKey: string;
|
||||
content: string;
|
||||
keywords?: string[];
|
||||
memoryKind?: string | null;
|
||||
sourceKind: MemorySourceKind;
|
||||
sourceRef?: string | null;
|
||||
},
|
||||
): number {
|
||||
const normalizedContent = input.content.trim();
|
||||
if (!normalizedContent) {
|
||||
throw new Error('Memory content cannot be empty');
|
||||
}
|
||||
const normalizedKeywords = normalizeMemoryKeywords(input.keywords);
|
||||
const createdAt = new Date().toISOString();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO memories (
|
||||
scope_kind,
|
||||
scope_key,
|
||||
content,
|
||||
keywords_json,
|
||||
memory_kind,
|
||||
source_kind,
|
||||
source_ref,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
input.scopeKind,
|
||||
input.scopeKey,
|
||||
normalizedContent,
|
||||
JSON.stringify(normalizedKeywords),
|
||||
input.memoryKind ?? null,
|
||||
input.sourceKind,
|
||||
input.sourceRef ?? null,
|
||||
createdAt,
|
||||
);
|
||||
const row = database.prepare('SELECT last_insert_rowid() AS id').get() as {
|
||||
id: number;
|
||||
};
|
||||
expireStaleMemoriesInDatabase(database, {
|
||||
scopeKind: input.scopeKind,
|
||||
scopeKey: input.scopeKey,
|
||||
});
|
||||
enforceMemoryBoundsInDatabase(database, input.scopeKind, input.scopeKey);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export function recallMemoriesFromDatabase(
|
||||
database: Database,
|
||||
query: RecallMemoryQuery,
|
||||
): MemoryRecord[] {
|
||||
const limit = Math.max(1, query.limit ?? 6);
|
||||
expireStaleMemoriesInDatabase(database, {
|
||||
scopeKind: query.scopeKind,
|
||||
scopeKey: query.scopeKey,
|
||||
});
|
||||
const rows = getMemoryRowsForScope(database, query.scopeKind, query.scopeKey);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const exactKeywords = new Set(normalizeMemoryKeywords(query.keywords));
|
||||
const ftsOrder = queryFtsRowOrder(database, query);
|
||||
const useQueryScoring = exactKeywords.size > 0 || Boolean(query.text?.trim());
|
||||
|
||||
const scored = rows
|
||||
.map((row, index) => {
|
||||
const keywords = parseMemoryKeywords(row.keywords_json);
|
||||
const exactMatches = keywords.filter((keyword) =>
|
||||
exactKeywords.has(keyword),
|
||||
).length;
|
||||
const ftsScore = ftsOrder.get(row.id) ?? 0;
|
||||
const recencyScore = rows.length - index;
|
||||
return {
|
||||
row,
|
||||
matched: exactMatches > 0 || ftsScore > 0,
|
||||
score: exactMatches * 100 + ftsScore * 10 + recencyScore,
|
||||
};
|
||||
})
|
||||
.filter((entry) => (useQueryScoring ? entry.matched : true))
|
||||
.sort((a, b) => b.score - a.score || b.row.id - a.row.id)
|
||||
.slice(0, limit);
|
||||
|
||||
const memories = scored.map((entry) => mapMemoryRow(entry.row));
|
||||
touchMemoriesInDatabase(
|
||||
database,
|
||||
memories.map((memory) => memory.id),
|
||||
);
|
||||
return memories;
|
||||
}
|
||||
385
src/db/messages.ts
Normal file
385
src/db/messages.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { NewMessage } from '../types.js';
|
||||
|
||||
export interface ChatInfo {
|
||||
jid: string;
|
||||
name: string;
|
||||
last_message_time: string;
|
||||
channel: string;
|
||||
is_group: number;
|
||||
}
|
||||
|
||||
function normalizeMessageRow(
|
||||
row: NewMessage & {
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
},
|
||||
): NewMessage {
|
||||
return {
|
||||
...row,
|
||||
is_from_me: !!row.is_from_me,
|
||||
is_bot_message: !!row.is_bot_message,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSeqCursor(
|
||||
cursor: string | number | null | undefined,
|
||||
): number {
|
||||
if (typeof cursor === 'number') {
|
||||
return Number.isFinite(cursor) && cursor > 0 ? cursor : 0;
|
||||
}
|
||||
if (!cursor) return 0;
|
||||
const parsed = Number.parseInt(cursor, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function storeChatMetadataInDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
timestamp: string,
|
||||
name?: string,
|
||||
channel?: string,
|
||||
isGroup?: boolean,
|
||||
): void {
|
||||
const ch = channel ?? null;
|
||||
const group = isGroup === undefined ? null : isGroup ? 1 : 0;
|
||||
|
||||
if (name) {
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
||||
channel = COALESCE(excluded.channel, channel),
|
||||
is_group = COALESCE(excluded.is_group, is_group)
|
||||
`,
|
||||
)
|
||||
.run(chatJid, name, timestamp, ch, group);
|
||||
} else {
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jid) DO UPDATE SET
|
||||
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
||||
channel = COALESCE(excluded.channel, channel),
|
||||
is_group = COALESCE(excluded.is_group, is_group)
|
||||
`,
|
||||
)
|
||||
.run(chatJid, chatJid, timestamp, ch, group);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllChatsFromDatabase(database: Database): ChatInfo[] {
|
||||
return database
|
||||
.prepare(
|
||||
`
|
||||
SELECT jid, name, last_message_time, channel, is_group
|
||||
FROM chats
|
||||
ORDER BY last_message_time DESC
|
||||
`,
|
||||
)
|
||||
.all() as ChatInfo[];
|
||||
}
|
||||
|
||||
export function storeMessageInDatabase(
|
||||
database: Database,
|
||||
msg: NewMessage,
|
||||
): void {
|
||||
const nextSeq = () => {
|
||||
database.prepare('INSERT INTO message_sequence DEFAULT VALUES').run();
|
||||
return (
|
||||
database.prepare('SELECT last_insert_rowid() as id').get() as {
|
||||
id: number;
|
||||
}
|
||||
).id;
|
||||
};
|
||||
|
||||
database.transaction(() => {
|
||||
const existing = database
|
||||
.prepare('SELECT seq FROM messages WHERE id = ? AND chat_jid = ?')
|
||||
.get(msg.id, msg.chat_jid) as { seq: number | null } | undefined;
|
||||
const seq = existing?.seq ?? nextSeq();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO messages (
|
||||
id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id, chat_jid) DO UPDATE SET
|
||||
sender = excluded.sender,
|
||||
sender_name = excluded.sender_name,
|
||||
content = excluded.content,
|
||||
timestamp = excluded.timestamp,
|
||||
is_from_me = excluded.is_from_me,
|
||||
is_bot_message = excluded.is_bot_message`,
|
||||
)
|
||||
.run(
|
||||
msg.id,
|
||||
msg.chat_jid,
|
||||
msg.sender,
|
||||
msg.sender_name,
|
||||
msg.content,
|
||||
msg.timestamp,
|
||||
seq,
|
||||
msg.is_from_me ? 1 : 0,
|
||||
msg.is_bot_message ? 1 : 0,
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
export function getNewMessagesFromDatabase(
|
||||
database: Database,
|
||||
jids: string[],
|
||||
lastTimestamp: string,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): { messages: NewMessage[]; newTimestamp: string } {
|
||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
`;
|
||||
|
||||
const rows = database
|
||||
.prepare(sql)
|
||||
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as Array<
|
||||
NewMessage & {
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
}
|
||||
>;
|
||||
|
||||
let newTimestamp = lastTimestamp;
|
||||
for (const row of rows) {
|
||||
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
|
||||
}
|
||||
|
||||
return { messages: rows.map(normalizeMessageRow), newTimestamp };
|
||||
}
|
||||
|
||||
export function getMessagesSinceFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
sinceTimestamp: string,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): NewMessage[] {
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp > ?
|
||||
AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
`;
|
||||
const rows = database
|
||||
.prepare(sql)
|
||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as Array<
|
||||
NewMessage & {
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
}
|
||||
>;
|
||||
return rows.map(normalizeMessageRow);
|
||||
}
|
||||
|
||||
export function getLatestMessageSeqAtOrBeforeFromDatabase(
|
||||
database: Database,
|
||||
timestamp: string,
|
||||
chatJid?: string,
|
||||
): number {
|
||||
if (!timestamp) return 0;
|
||||
const row = (
|
||||
chatJid
|
||||
? database
|
||||
.prepare(
|
||||
`SELECT COALESCE(MAX(seq), 0) AS maxSeq
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp <= ?`,
|
||||
)
|
||||
.get(chatJid, timestamp)
|
||||
: database
|
||||
.prepare(
|
||||
`SELECT COALESCE(MAX(seq), 0) AS maxSeq
|
||||
FROM messages
|
||||
WHERE timestamp <= ?`,
|
||||
)
|
||||
.get(timestamp)
|
||||
) as { maxSeq: number | null };
|
||||
return row.maxSeq ?? 0;
|
||||
}
|
||||
|
||||
export function getNewMessagesBySeqFromDatabase(
|
||||
database: Database,
|
||||
jids: string[],
|
||||
lastSeqCursor: string | number,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): { messages: NewMessage[]; newSeqCursor: string } {
|
||||
const sinceSeq = normalizeSeqCursor(lastSeqCursor);
|
||||
if (jids.length === 0) {
|
||||
return { messages: [], newSeqCursor: String(sinceSeq) };
|
||||
}
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
const sql = `
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
||||
FROM messages
|
||||
WHERE seq > ? AND chat_jid IN (${placeholders})
|
||||
AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY seq
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
const rows = database
|
||||
.prepare(sql)
|
||||
.all(sinceSeq, ...jids, `${botPrefix}:%`, limit) as Array<
|
||||
NewMessage & {
|
||||
seq: number;
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
}
|
||||
>;
|
||||
|
||||
const lastSeq = rows.length > 0 ? rows[rows.length - 1].seq : sinceSeq;
|
||||
return {
|
||||
messages: rows.map(normalizeMessageRow),
|
||||
newSeqCursor: String(lastSeq),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMessagesSinceSeqFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
sinceSeqCursor: string | number,
|
||||
botPrefix: string,
|
||||
limit: number = 200,
|
||||
): NewMessage[] {
|
||||
const sinceSeq = normalizeSeqCursor(sinceSeqCursor);
|
||||
const sql = `
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND seq > ?
|
||||
AND content NOT LIKE ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY seq
|
||||
LIMIT ?
|
||||
`;
|
||||
const rows = database
|
||||
.prepare(sql)
|
||||
.all(chatJid, sinceSeq, `${botPrefix}:%`, limit) as Array<
|
||||
NewMessage & {
|
||||
seq: number;
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
}
|
||||
>;
|
||||
return rows.map(normalizeMessageRow);
|
||||
}
|
||||
|
||||
export function getRecentChatMessagesFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
limit: number = 20,
|
||||
): NewMessage[] {
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
FROM messages
|
||||
WHERE chat_jid = ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
`;
|
||||
const rows = database.prepare(sql).all(chatJid, limit) as Array<
|
||||
NewMessage & {
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
}
|
||||
>;
|
||||
return rows.map(normalizeMessageRow);
|
||||
}
|
||||
|
||||
export function getLastHumanMessageTimestampFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): string | null {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT timestamp FROM messages
|
||||
WHERE chat_jid = ? AND is_bot_message = 0 AND is_from_me = 0
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC LIMIT 1`,
|
||||
)
|
||||
.get(chatJid) as { timestamp: string } | undefined;
|
||||
return row?.timestamp ?? null;
|
||||
}
|
||||
|
||||
export function getLastHumanMessageSenderFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): string | null {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT sender FROM messages
|
||||
WHERE chat_jid = ? AND is_bot_message = 0 AND is_from_me = 0
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC, seq DESC LIMIT 1`,
|
||||
)
|
||||
.get(chatJid) as { sender: string } | undefined;
|
||||
return row?.sender ?? null;
|
||||
}
|
||||
|
||||
export function getLastHumanMessageContentFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): string | null {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT content FROM messages
|
||||
WHERE chat_jid = ? AND is_bot_message = 0 AND is_from_me = 0
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC, seq DESC LIMIT 1`,
|
||||
)
|
||||
.get(chatJid) as { content: string } | undefined;
|
||||
return row?.content ?? null;
|
||||
}
|
||||
|
||||
export function hasRecentRestartAnnouncementInDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
sinceTimestamp: string,
|
||||
): boolean {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages
|
||||
WHERE chat_jid = ?
|
||||
AND timestamp >= ?
|
||||
AND is_bot_message = 1
|
||||
AND (
|
||||
content LIKE '재시작 완료.%'
|
||||
OR content LIKE '재시작 감지.%'
|
||||
OR content LIKE '서비스 재시작으로 이전 작업이 중단됐습니다.%'
|
||||
)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(chatJid, sinceTimestamp) as { 1: number } | undefined;
|
||||
return !!row;
|
||||
}
|
||||
678
src/db/room-registration.ts
Normal file
678
src/db/room-registration.ts
Normal file
@@ -0,0 +1,678 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
OWNER_AGENT_TYPE,
|
||||
REVIEWER_AGENT_TYPE,
|
||||
} from '../config.js';
|
||||
import { isValidGroupFolder } from '../group-folder.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
|
||||
|
||||
export type RoomModeSource = 'explicit' | 'inferred';
|
||||
|
||||
export interface StoredRoomSettings {
|
||||
chatJid: string;
|
||||
roomMode: RoomMode;
|
||||
modeSource: RoomModeSource;
|
||||
name?: string;
|
||||
folder?: string;
|
||||
trigger?: string;
|
||||
requiresTrigger?: boolean;
|
||||
isMain?: boolean;
|
||||
ownerAgentType?: AgentType;
|
||||
workDir?: string;
|
||||
}
|
||||
|
||||
export interface RoomRegistrationSnapshot {
|
||||
name: string;
|
||||
folder: string;
|
||||
triggerPattern: string;
|
||||
requiresTrigger: boolean;
|
||||
isMain: boolean;
|
||||
ownerAgentType: AgentType;
|
||||
workDir: string | null;
|
||||
}
|
||||
|
||||
export interface RegisteredGroupDatabaseRow {
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
added_at: string;
|
||||
agent_config: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
agent_type: string | null;
|
||||
work_dir: string | null;
|
||||
}
|
||||
|
||||
export function normalizeRoomModeSource(
|
||||
source: string | null | undefined,
|
||||
): RoomModeSource | undefined {
|
||||
return source === 'explicit' || source === 'inferred' ? source : undefined;
|
||||
}
|
||||
|
||||
export function normalizeStoredAgentType(
|
||||
agentType: string | null | undefined,
|
||||
): AgentType | undefined {
|
||||
return agentType === 'claude-code' || agentType === 'codex'
|
||||
? agentType
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function collectRegisteredAgentTypes(
|
||||
database: Database,
|
||||
jid: string,
|
||||
): AgentType[] {
|
||||
const rows = database
|
||||
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
|
||||
.all(jid) as Array<{ agent_type: string | null }>;
|
||||
|
||||
const types = new Set<AgentType>();
|
||||
for (const row of rows) {
|
||||
const agentType = normalizeStoredAgentType(row.agent_type);
|
||||
if (agentType) {
|
||||
types.add(agentType);
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
export function collectRegisteredAgentTypesForFolder(
|
||||
database: Database,
|
||||
folder: string,
|
||||
): AgentType[] {
|
||||
const rows = database
|
||||
.prepare('SELECT agent_type FROM registered_groups WHERE folder = ?')
|
||||
.all(folder) as Array<{ agent_type: string | null }>;
|
||||
|
||||
const types = new Set<AgentType>();
|
||||
for (const row of rows) {
|
||||
const agentType = normalizeStoredAgentType(row.agent_type);
|
||||
if (agentType) {
|
||||
types.add(agentType);
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
export function inferRoomModeFromRegisteredAgentTypes(
|
||||
agentTypes: readonly AgentType[],
|
||||
): RoomMode {
|
||||
const types = new Set(agentTypes);
|
||||
return types.has('claude-code') && types.has('codex') ? 'tribunal' : 'single';
|
||||
}
|
||||
|
||||
export function inferOwnerAgentTypeFromRegisteredAgentTypes(
|
||||
agentTypes: readonly AgentType[],
|
||||
): AgentType {
|
||||
const types = new Set(agentTypes);
|
||||
if (types.has(OWNER_AGENT_TYPE)) return OWNER_AGENT_TYPE;
|
||||
if (types.has('codex')) return 'codex';
|
||||
return 'claude-code';
|
||||
}
|
||||
|
||||
export function parseRegisteredGroupRow(
|
||||
row: RegisteredGroupDatabaseRow | undefined,
|
||||
): (RegisteredGroup & { jid: string }) | undefined {
|
||||
if (!row) return undefined;
|
||||
if (!isValidGroupFolder(row.folder)) {
|
||||
logger.warn(
|
||||
{ jid: row.jid, folder: row.folder },
|
||||
'Skipping registered group with invalid folder',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
jid: row.jid,
|
||||
name: row.name,
|
||||
folder: row.folder,
|
||||
trigger: row.trigger_pattern,
|
||||
added_at: row.added_at,
|
||||
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
isMain: row.is_main === 1 ? true : undefined,
|
||||
agentType: normalizeStoredAgentType(row.agent_type),
|
||||
workDir: row.work_dir || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLegacyRegisteredGroupRows(
|
||||
database: Database,
|
||||
agentTypeFilter?: string,
|
||||
): RegisteredGroupDatabaseRow[] {
|
||||
return (
|
||||
agentTypeFilter
|
||||
? database
|
||||
.prepare('SELECT * FROM registered_groups WHERE agent_type = ?')
|
||||
.all(agentTypeFilter)
|
||||
: database.prepare('SELECT * FROM registered_groups').all()
|
||||
) as RegisteredGroupDatabaseRow[];
|
||||
}
|
||||
|
||||
export function getLegacyRegisteredGroup(
|
||||
database: Database,
|
||||
jid: string,
|
||||
agentType?: string,
|
||||
): (RegisteredGroup & { jid: string }) | undefined {
|
||||
const row = (
|
||||
agentType
|
||||
? database
|
||||
.prepare(
|
||||
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?',
|
||||
)
|
||||
.get(jid, agentType)
|
||||
: database
|
||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||
.get(jid)
|
||||
) as RegisteredGroupDatabaseRow | undefined;
|
||||
|
||||
return parseRegisteredGroupRow(row);
|
||||
}
|
||||
|
||||
export function getRegisteredGroupCapabilityMetadata(
|
||||
database: Database,
|
||||
jid: string,
|
||||
preferredAgentType?: AgentType,
|
||||
): Pick<RegisteredGroup, 'added_at' | 'agentConfig'> | undefined {
|
||||
const row = (
|
||||
preferredAgentType
|
||||
? database
|
||||
.prepare(
|
||||
`SELECT added_at, agent_config
|
||||
FROM registered_groups
|
||||
WHERE jid = ? AND agent_type = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(jid, preferredAgentType)
|
||||
: database
|
||||
.prepare(
|
||||
`SELECT added_at, agent_config
|
||||
FROM registered_groups
|
||||
WHERE jid = ?
|
||||
ORDER BY CASE WHEN agent_type = ? THEN 0 ELSE 1 END, added_at
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(jid, OWNER_AGENT_TYPE)
|
||||
) as { added_at: string; agent_config: string | null } | undefined;
|
||||
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
added_at: row.added_at,
|
||||
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStoredRoomSettingsRowFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): StoredRoomSettings | undefined {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT room_mode, mode_source, name, folder, trigger_pattern,
|
||||
requires_trigger, is_main, owner_agent_type, work_dir
|
||||
FROM room_settings
|
||||
WHERE chat_jid = ?`,
|
||||
)
|
||||
.get(chatJid) as
|
||||
| {
|
||||
room_mode: string | null;
|
||||
mode_source: string | null;
|
||||
name: string | null;
|
||||
folder: string | null;
|
||||
trigger_pattern: string | null;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
owner_agent_type: string | null;
|
||||
work_dir: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const roomMode =
|
||||
row?.room_mode === 'single' || row?.room_mode === 'tribunal'
|
||||
? row.room_mode
|
||||
: undefined;
|
||||
const source = normalizeRoomModeSource(row?.mode_source);
|
||||
if (!row || !roomMode || !source) return undefined;
|
||||
|
||||
return {
|
||||
chatJid,
|
||||
roomMode,
|
||||
modeSource: source,
|
||||
name: row.name ?? undefined,
|
||||
folder: row.folder ?? undefined,
|
||||
trigger: row.trigger_pattern ?? undefined,
|
||||
requiresTrigger:
|
||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||
isMain: row.is_main === null ? undefined : row.is_main === 1,
|
||||
ownerAgentType: normalizeStoredAgentType(row.owner_agent_type),
|
||||
workDir: row.work_dir ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStoredRoomRowsFromDatabase(
|
||||
database: Database,
|
||||
): StoredRoomSettings[] {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT chat_jid
|
||||
FROM room_settings
|
||||
ORDER BY chat_jid`,
|
||||
)
|
||||
.all()
|
||||
.map((row) =>
|
||||
getStoredRoomSettingsRowFromDatabase(
|
||||
database,
|
||||
(row as { chat_jid: string }).chat_jid,
|
||||
),
|
||||
)
|
||||
.filter((row): row is StoredRoomSettings => Boolean(row));
|
||||
}
|
||||
|
||||
export function buildRegisteredGroupFromStoredSettings(
|
||||
database: Database,
|
||||
stored: StoredRoomSettings,
|
||||
requestedAgentType?: AgentType,
|
||||
): (RegisteredGroup & { jid: string }) | undefined {
|
||||
const capabilityTypes = collectRegisteredAgentTypes(database, stored.chatJid);
|
||||
const resolvedAgentType = requestedAgentType
|
||||
? capabilityTypes.includes(requestedAgentType)
|
||||
? requestedAgentType
|
||||
: undefined
|
||||
: stored.ownerAgentType ||
|
||||
(capabilityTypes.length > 0
|
||||
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
|
||||
: undefined);
|
||||
|
||||
if (!resolvedAgentType) return undefined;
|
||||
if (!stored.folder || !isValidGroupFolder(stored.folder)) {
|
||||
logger.warn(
|
||||
{ jid: stored.chatJid, folder: stored.folder ?? null },
|
||||
'Skipping stored room with invalid folder',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const capabilityMetadata = getRegisteredGroupCapabilityMetadata(
|
||||
database,
|
||||
stored.chatJid,
|
||||
resolvedAgentType,
|
||||
);
|
||||
|
||||
return {
|
||||
jid: stored.chatJid,
|
||||
name: stored.name || stored.chatJid,
|
||||
folder: stored.folder,
|
||||
trigger: stored.trigger || `@${ASSISTANT_NAME}`,
|
||||
added_at: capabilityMetadata?.added_at || new Date(0).toISOString(),
|
||||
agentConfig: capabilityMetadata?.agentConfig,
|
||||
requiresTrigger: stored.requiresTrigger,
|
||||
isMain: stored.isMain ? true : undefined,
|
||||
agentType: resolvedAgentType,
|
||||
workDir: stored.workDir,
|
||||
};
|
||||
}
|
||||
|
||||
function detectChannelPrefixForFolder(chatJid: string): string {
|
||||
if (chatJid.startsWith('dc:')) return 'discord';
|
||||
if (chatJid.startsWith('tg:')) return 'telegram';
|
||||
return 'whatsapp';
|
||||
}
|
||||
|
||||
function slugifyGroupFolderSegment(value: string): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
return slug || 'room';
|
||||
}
|
||||
|
||||
function collectReservedFolders(
|
||||
database: Database,
|
||||
exceptChatJid?: string,
|
||||
): Set<string> {
|
||||
const folders = new Set<string>();
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT folder
|
||||
FROM room_settings
|
||||
WHERE folder IS NOT NULL
|
||||
AND (? IS NULL OR chat_jid != ?)
|
||||
UNION
|
||||
SELECT folder
|
||||
FROM registered_groups
|
||||
WHERE ? IS NULL OR jid != ?`,
|
||||
)
|
||||
.all(
|
||||
exceptChatJid ?? null,
|
||||
exceptChatJid ?? null,
|
||||
exceptChatJid ?? null,
|
||||
exceptChatJid ?? null,
|
||||
) as Array<{
|
||||
folder: string | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.folder) folders.add(row.folder);
|
||||
}
|
||||
return folders;
|
||||
}
|
||||
|
||||
function buildGeneratedRoomFolder(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
name: string,
|
||||
): string {
|
||||
const prefix = detectChannelPrefixForFolder(chatJid);
|
||||
const slug = slugifyGroupFolderSegment(name);
|
||||
const fallback = slugifyGroupFolderSegment(chatJid.replace(/[:@.]/g, '-'));
|
||||
const baseCore = slug || fallback;
|
||||
const maxBaseLength = 64 - (`grp_${prefix}_`.length + 4);
|
||||
const truncatedCore = baseCore.slice(0, Math.max(8, maxBaseLength));
|
||||
const candidateBase = `grp_${prefix}_${truncatedCore}`;
|
||||
const reserved = collectReservedFolders(database, chatJid);
|
||||
|
||||
if (!reserved.has(candidateBase) && isValidGroupFolder(candidateBase)) {
|
||||
return candidateBase;
|
||||
}
|
||||
|
||||
for (let suffix = 2; suffix < 1000; suffix += 1) {
|
||||
const candidate = `${candidateBase.slice(0, Math.max(1, 64 - `${suffix}`.length - 1))}-${suffix}`;
|
||||
if (!reserved.has(candidate) && isValidGroupFolder(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to generate unique group folder for ${chatJid}`);
|
||||
}
|
||||
|
||||
export function resolveAssignedRoomFolder(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
name: string,
|
||||
explicitFolder?: string,
|
||||
): string {
|
||||
const reserved = collectReservedFolders(database, chatJid);
|
||||
if (explicitFolder) {
|
||||
if (!isValidGroupFolder(explicitFolder)) {
|
||||
throw new Error(
|
||||
`Invalid group folder "${explicitFolder}" for JID ${chatJid}`,
|
||||
);
|
||||
}
|
||||
if (reserved.has(explicitFolder)) {
|
||||
throw new Error(`Group folder "${explicitFolder}" is already assigned`);
|
||||
}
|
||||
return explicitFolder;
|
||||
}
|
||||
|
||||
const existingFolder = getStoredRoomSettingsRowFromDatabase(
|
||||
database,
|
||||
chatJid,
|
||||
)?.folder;
|
||||
if (existingFolder) return existingFolder;
|
||||
|
||||
return buildGeneratedRoomFolder(database, chatJid, name);
|
||||
}
|
||||
|
||||
function getDesiredRegisteredAgentTypes(
|
||||
roomMode: RoomMode,
|
||||
ownerAgentType: AgentType,
|
||||
): AgentType[] {
|
||||
const types = new Set<AgentType>([ownerAgentType]);
|
||||
if (roomMode === 'tribunal') {
|
||||
types.add(REVIEWER_AGENT_TYPE);
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
export function materializeRegisteredGroupsForRoom(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
snapshot: RoomRegistrationSnapshot,
|
||||
roomMode: RoomMode,
|
||||
ownerAgentType: AgentType,
|
||||
ownerAgentConfig?: RegisteredGroup['agentConfig'],
|
||||
addedAt?: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
const desiredTypes = getDesiredRegisteredAgentTypes(roomMode, ownerAgentType);
|
||||
const existingRows = database
|
||||
.prepare(
|
||||
`SELECT agent_type, added_at, agent_config
|
||||
FROM registered_groups
|
||||
WHERE jid = ?`,
|
||||
)
|
||||
.all(chatJid) as Array<{
|
||||
agent_type: string | null;
|
||||
added_at: string;
|
||||
agent_config: string | null;
|
||||
}>;
|
||||
const existingByType = new Map<
|
||||
AgentType,
|
||||
{ added_at: string; agent_config: string | null }
|
||||
>();
|
||||
for (const row of existingRows) {
|
||||
const agentType = normalizeStoredAgentType(row.agent_type);
|
||||
if (agentType) {
|
||||
existingByType.set(agentType, row);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentType of desiredTypes) {
|
||||
const existing = existingByType.get(agentType);
|
||||
const agentConfig =
|
||||
agentType === ownerAgentType
|
||||
? (ownerAgentConfig ??
|
||||
(existing?.agent_config
|
||||
? JSON.parse(existing.agent_config)
|
||||
: undefined))
|
||||
: existing?.agent_config
|
||||
? JSON.parse(existing.agent_config)
|
||||
: undefined;
|
||||
const rowAddedAt = existing?.added_at ?? addedAt ?? now;
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups (
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
agent_config,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
agent_type,
|
||||
work_dir
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
chatJid,
|
||||
snapshot.name,
|
||||
snapshot.folder,
|
||||
snapshot.triggerPattern,
|
||||
rowAddedAt,
|
||||
agentConfig ? JSON.stringify(agentConfig) : null,
|
||||
snapshot.requiresTrigger ? 1 : 0,
|
||||
snapshot.isMain ? 1 : 0,
|
||||
agentType,
|
||||
snapshot.workDir,
|
||||
);
|
||||
}
|
||||
|
||||
const placeholders = desiredTypes.map(() => '?').join(',');
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM registered_groups
|
||||
WHERE jid = ?
|
||||
AND agent_type NOT IN (${placeholders})`,
|
||||
)
|
||||
.run(chatJid, ...desiredTypes);
|
||||
}
|
||||
|
||||
export function collectRoomRegistrationSnapshot(
|
||||
database: Database,
|
||||
jid: string,
|
||||
existingStored?: Pick<
|
||||
StoredRoomSettings,
|
||||
'modeSource' | 'ownerAgentType' | 'trigger'
|
||||
>,
|
||||
): RoomRegistrationSnapshot | undefined {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT name, folder, trigger_pattern, requires_trigger, is_main, agent_type, work_dir
|
||||
FROM registered_groups
|
||||
WHERE jid = ?
|
||||
ORDER BY agent_type`,
|
||||
)
|
||||
.all(jid) as Array<{
|
||||
name: string;
|
||||
folder: string;
|
||||
trigger_pattern: string;
|
||||
requires_trigger: number | null;
|
||||
is_main: number | null;
|
||||
agent_type: string | null;
|
||||
work_dir: string | null;
|
||||
}>;
|
||||
|
||||
if (rows.length === 0) return undefined;
|
||||
|
||||
const first = rows[0];
|
||||
const conflicts = new Set<string>();
|
||||
for (const row of rows.slice(1)) {
|
||||
if (row.name !== first.name) conflicts.add('name');
|
||||
if (row.folder !== first.folder) conflicts.add('folder');
|
||||
if ((row.requires_trigger ?? 1) !== (first.requires_trigger ?? 1)) {
|
||||
conflicts.add('requires_trigger');
|
||||
}
|
||||
if ((row.is_main ?? 0) !== (first.is_main ?? 0)) {
|
||||
conflicts.add('is_main');
|
||||
}
|
||||
if ((row.work_dir ?? null) !== (first.work_dir ?? null)) {
|
||||
conflicts.add('work_dir');
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.size > 0) {
|
||||
throw new Error(
|
||||
`Conflicting room-level registered_groups metadata for ${jid}: ${[
|
||||
...conflicts,
|
||||
].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const agentTypes = collectRegisteredAgentTypes(database, jid);
|
||||
const inferredOwnerAgentType =
|
||||
inferOwnerAgentTypeFromRegisteredAgentTypes(agentTypes);
|
||||
const preferExplicitTrigger =
|
||||
existingStored?.modeSource === 'explicit' && existingStored.trigger;
|
||||
const preferExplicitOwner =
|
||||
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType;
|
||||
const preferredOwnerAgentType = preferExplicitOwner
|
||||
? existingStored.ownerAgentType
|
||||
: undefined;
|
||||
const preferredOwnerRow = preferredOwnerAgentType
|
||||
? rows.find(
|
||||
(row) =>
|
||||
normalizeStoredAgentType(row.agent_type) === preferredOwnerAgentType,
|
||||
)
|
||||
: undefined;
|
||||
const inferredOwnerRow =
|
||||
rows.find(
|
||||
(row) =>
|
||||
normalizeStoredAgentType(row.agent_type) === inferredOwnerAgentType,
|
||||
) ?? rows[0];
|
||||
const ownerAgentType = preferredOwnerAgentType
|
||||
? preferredOwnerRow
|
||||
? preferredOwnerAgentType
|
||||
: preferredOwnerAgentType
|
||||
: inferredOwnerAgentType;
|
||||
const ownerRow = preferredOwnerRow ?? inferredOwnerRow;
|
||||
|
||||
return {
|
||||
name: first.name,
|
||||
folder: first.folder,
|
||||
triggerPattern: preferExplicitTrigger
|
||||
? existingStored.trigger!
|
||||
: preferredOwnerRow != null
|
||||
? preferredOwnerRow.trigger_pattern
|
||||
: ownerRow.trigger_pattern,
|
||||
requiresTrigger: (first.requires_trigger ?? 1) === 1,
|
||||
isMain: (first.is_main ?? 0) === 1,
|
||||
ownerAgentType,
|
||||
workDir: first.work_dir ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertStoredRoomSettings(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
roomMode: RoomMode,
|
||||
source: RoomModeSource,
|
||||
snapshot: RoomRegistrationSnapshot,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO room_settings (
|
||||
chat_jid,
|
||||
room_mode,
|
||||
mode_source,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
owner_agent_type,
|
||||
work_dir,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
chatJid,
|
||||
roomMode,
|
||||
source,
|
||||
snapshot.name,
|
||||
snapshot.folder,
|
||||
snapshot.triggerPattern,
|
||||
snapshot.requiresTrigger ? 1 : 0,
|
||||
snapshot.isMain ? 1 : 0,
|
||||
snapshot.ownerAgentType,
|
||||
snapshot.workDir,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateStoredRoomMetadata(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
snapshot: RoomRegistrationSnapshot,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE room_settings
|
||||
SET name = ?,
|
||||
folder = ?,
|
||||
trigger_pattern = ?,
|
||||
requires_trigger = ?,
|
||||
is_main = ?,
|
||||
owner_agent_type = ?,
|
||||
work_dir = ?,
|
||||
updated_at = ?
|
||||
WHERE chat_jid = ?`,
|
||||
)
|
||||
.run(
|
||||
snapshot.name,
|
||||
snapshot.folder,
|
||||
snapshot.triggerPattern,
|
||||
snapshot.requiresTrigger ? 1 : 0,
|
||||
snapshot.isMain ? 1 : 0,
|
||||
snapshot.ownerAgentType,
|
||||
snapshot.workDir,
|
||||
new Date().toISOString(),
|
||||
chatJid,
|
||||
);
|
||||
}
|
||||
87
src/db/router-state.ts
Normal file
87
src/db/router-state.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { normalizeServiceId } from '../config.js';
|
||||
import { AgentType } from '../types.js';
|
||||
|
||||
export function getRouterStateFromDatabase(
|
||||
database: Database,
|
||||
key: string,
|
||||
currentServiceId: string,
|
||||
): string | undefined {
|
||||
const row = database
|
||||
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
if (row) return row.value;
|
||||
|
||||
const prefixedKey = `${normalizeServiceId(currentServiceId)}:${key}`;
|
||||
const prefixedRow = database
|
||||
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||
.get(prefixedKey) as { value: string } | undefined;
|
||||
if (!prefixedRow) return undefined;
|
||||
|
||||
database
|
||||
.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)')
|
||||
.run(key, prefixedRow.value);
|
||||
database.prepare('DELETE FROM router_state WHERE key = ?').run(prefixedKey);
|
||||
return prefixedRow.value;
|
||||
}
|
||||
|
||||
export function getRouterStateForServiceFromDatabase(
|
||||
database: Database,
|
||||
key: string,
|
||||
serviceId: string,
|
||||
): string | undefined {
|
||||
const prefixedKey = `${normalizeServiceId(serviceId)}:${key}`;
|
||||
const row = database
|
||||
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||
.get(prefixedKey) as { value: string } | undefined;
|
||||
if (row) return row.value;
|
||||
|
||||
const canonical = database
|
||||
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
return canonical?.value;
|
||||
}
|
||||
|
||||
export function setRouterStateInDatabase(
|
||||
database: Database,
|
||||
key: string,
|
||||
value: string,
|
||||
): void {
|
||||
database
|
||||
.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)')
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
export function setRouterStateForServiceInDatabase(
|
||||
database: Database,
|
||||
key: string,
|
||||
value: string,
|
||||
serviceId: string,
|
||||
): void {
|
||||
const prefixedKey = `${normalizeServiceId(serviceId)}:${key}`;
|
||||
database
|
||||
.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)')
|
||||
.run(prefixedKey, value);
|
||||
}
|
||||
|
||||
export function getLastRespondingAgentTypeFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): AgentType | undefined {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT sender FROM messages
|
||||
WHERE chat_jid = ? AND is_bot_message = 1
|
||||
ORDER BY timestamp DESC, seq DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(chatJid) as { sender: string } | undefined;
|
||||
|
||||
if (!row) return undefined;
|
||||
|
||||
const sender = row.sender.toLowerCase();
|
||||
if (sender.includes('claude')) return 'claude-code';
|
||||
if (sender.includes('codex')) return 'codex';
|
||||
return undefined;
|
||||
}
|
||||
545
src/db/schema.ts
Normal file
545
src/db/schema.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
|
||||
import {
|
||||
backfillLegacyServiceSessions,
|
||||
dropLegacyServiceSessionsTable,
|
||||
migrateSessionsTableToCompositePk,
|
||||
} from './sessions.js';
|
||||
|
||||
export interface SchemaMigrationHooks {
|
||||
backfillMessageSeq(database: Database): void;
|
||||
backfillStoredRoomSettings(database: Database): void;
|
||||
backfillChannelOwnerRoleMetadata(database: Database): void;
|
||||
backfillWorkItemServiceShadows(database: Database): void;
|
||||
backfillServiceHandoffServiceShadows(database: Database): void;
|
||||
backfillPairedTaskRoleMetadata(database: Database): void;
|
||||
rebuildWorkItemsCanonicalSchema(database: Database): void;
|
||||
rebuildChannelOwnerCanonicalSchema(database: Database): void;
|
||||
rebuildPairedTasksCanonicalSchema(database: Database): void;
|
||||
rebuildServiceHandoffsCanonicalSchema(database: Database): void;
|
||||
}
|
||||
|
||||
function getTableColumns(database: Database, tableName: string): string[] {
|
||||
return (
|
||||
database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
|
||||
name: string;
|
||||
}>
|
||||
).map((column) => column.name);
|
||||
}
|
||||
|
||||
export function tableHasColumn(
|
||||
database: Database,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean {
|
||||
return getTableColumns(database, tableName).includes(columnName);
|
||||
}
|
||||
|
||||
function tryExecMigration(database: Database, sql: string): void {
|
||||
try {
|
||||
database.exec(sql);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
}
|
||||
|
||||
export function applySchemaMigrations(
|
||||
database: Database,
|
||||
args: {
|
||||
assistantName: string;
|
||||
hooks: SchemaMigrationHooks;
|
||||
},
|
||||
): void {
|
||||
const { assistantName, hooks } = args;
|
||||
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`,
|
||||
);
|
||||
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`,
|
||||
);
|
||||
tryExecMigration(database, `ALTER TABLE room_settings ADD COLUMN name TEXT`);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE room_settings ADD COLUMN folder TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`,
|
||||
);
|
||||
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE service_handoffs ADD COLUMN source_role TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE service_handoffs ADD COLUMN target_role TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE service_handoffs ADD COLUMN source_agent_type TEXT`,
|
||||
);
|
||||
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE paired_tasks ADD COLUMN owner_agent_type TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE paired_tasks ADD COLUMN reviewer_agent_type TEXT`,
|
||||
);
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE paired_tasks ADD COLUMN arbiter_agent_type TEXT`,
|
||||
);
|
||||
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE work_items ADD COLUMN delivery_role TEXT`,
|
||||
);
|
||||
|
||||
database.exec(
|
||||
`UPDATE service_handoffs
|
||||
SET target_role = COALESCE(
|
||||
target_role,
|
||||
intended_role,
|
||||
CASE
|
||||
WHEN reason LIKE 'reviewer-%' THEN 'reviewer'
|
||||
WHEN reason LIKE 'arbiter-%' THEN 'arbiter'
|
||||
WHEN reason IS NOT NULL THEN 'owner'
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
WHERE target_role IS NULL`,
|
||||
);
|
||||
|
||||
database.exec(
|
||||
`UPDATE service_handoffs
|
||||
SET source_role = COALESCE(source_role, target_role, intended_role)
|
||||
WHERE source_role IS NULL`,
|
||||
);
|
||||
|
||||
database.exec(
|
||||
`UPDATE room_settings
|
||||
SET mode_source = 'explicit'
|
||||
WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`,
|
||||
);
|
||||
|
||||
database.exec(`
|
||||
UPDATE scheduled_tasks
|
||||
SET agent_type = COALESCE(
|
||||
(
|
||||
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
|
||||
FROM registered_groups
|
||||
WHERE jid = scheduled_tasks.chat_jid
|
||||
AND folder = scheduled_tasks.group_folder
|
||||
),
|
||||
(
|
||||
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
|
||||
FROM registered_groups
|
||||
WHERE jid = scheduled_tasks.chat_jid
|
||||
),
|
||||
(
|
||||
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
|
||||
FROM registered_groups
|
||||
WHERE folder = scheduled_tasks.group_folder
|
||||
)
|
||||
)
|
||||
WHERE agent_type IS NULL;
|
||||
`);
|
||||
|
||||
try {
|
||||
database.exec(
|
||||
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
|
||||
);
|
||||
database
|
||||
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
|
||||
.run(`${assistantName}:%`);
|
||||
} catch {
|
||||
/* column already exists */
|
||||
}
|
||||
|
||||
tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`);
|
||||
|
||||
hooks.backfillMessageSeq(database);
|
||||
|
||||
database.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq);
|
||||
`);
|
||||
database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`);
|
||||
database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`);
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent
|
||||
ON work_items(chat_jid, agent_type, delivery_role, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
|
||||
ON work_items(chat_jid, agent_type, IFNULL(delivery_role, ''))
|
||||
WHERE status IN ('produced', 'delivery_retry');
|
||||
`);
|
||||
|
||||
const registeredGroupsSql = (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined
|
||||
)?.sql;
|
||||
if (
|
||||
registeredGroupsSql &&
|
||||
!registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)')
|
||||
) {
|
||||
const registeredGroupCols = database
|
||||
.prepare('PRAGMA table_info(registered_groups)')
|
||||
.all() as Array<{ name: string }>;
|
||||
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
|
||||
const hasAgentType = registeredGroupCols.some(
|
||||
(col) => col.name === 'agent_type',
|
||||
);
|
||||
const hasWorkDir = registeredGroupCols.some(
|
||||
(col) => col.name === 'work_dir',
|
||||
);
|
||||
const hasAgentConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'agent_config',
|
||||
);
|
||||
const hasContainerConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'container_config',
|
||||
);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE registered_groups_new (
|
||||
jid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
agent_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1,
|
||||
is_main INTEGER DEFAULT 0,
|
||||
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
||||
work_dir TEXT,
|
||||
PRIMARY KEY (jid, agent_type),
|
||||
UNIQUE (folder, agent_type)
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
INSERT INTO registered_groups_new (
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
agent_config,
|
||||
requires_trigger,
|
||||
is_main,
|
||||
agent_type,
|
||||
work_dir
|
||||
)
|
||||
SELECT
|
||||
jid,
|
||||
name,
|
||||
folder,
|
||||
trigger_pattern,
|
||||
added_at,
|
||||
${
|
||||
hasAgentConfig
|
||||
? 'agent_config'
|
||||
: hasContainerConfig
|
||||
? 'container_config'
|
||||
: 'NULL'
|
||||
},
|
||||
requires_trigger,
|
||||
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
|
||||
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
|
||||
${hasWorkDir ? 'work_dir' : 'NULL'}
|
||||
FROM registered_groups;
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
DROP TABLE registered_groups;
|
||||
ALTER TABLE registered_groups_new RENAME TO registered_groups;
|
||||
`);
|
||||
} else {
|
||||
database.exec(
|
||||
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
|
||||
);
|
||||
}
|
||||
|
||||
const registeredGroupCols = database
|
||||
.prepare('PRAGMA table_info(registered_groups)')
|
||||
.all() as Array<{ name: string }>;
|
||||
const hasAgentConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'agent_config',
|
||||
);
|
||||
const hasContainerConfig = registeredGroupCols.some(
|
||||
(col) => col.name === 'container_config',
|
||||
);
|
||||
if (!hasAgentConfig) {
|
||||
database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`);
|
||||
}
|
||||
if (hasContainerConfig) {
|
||||
database.exec(
|
||||
`UPDATE registered_groups
|
||||
SET agent_config = COALESCE(agent_config, container_config)
|
||||
WHERE container_config IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
const pairedTasksSqlRow = database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
|
||||
const pairedTasksNeedsRebuild =
|
||||
pairedTasksSql &&
|
||||
(pairedTasksSql.includes('task_policy') ||
|
||||
!pairedTasksSql.includes('round_trip_count'));
|
||||
if (pairedTasksNeedsRebuild) {
|
||||
database.exec(`DROP TABLE IF EXISTS paired_tasks`);
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS paired_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
owner_agent_type TEXT,
|
||||
reviewer_agent_type TEXT,
|
||||
arbiter_agent_type TEXT,
|
||||
title TEXT,
|
||||
source_ref TEXT,
|
||||
plan_notes TEXT,
|
||||
review_requested_at TEXT,
|
||||
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
arbiter_verdict TEXT,
|
||||
arbiter_requested_at TEXT,
|
||||
completion_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
||||
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
||||
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||
ON paired_tasks(chat_jid, status, updated_at);
|
||||
`);
|
||||
}
|
||||
|
||||
{
|
||||
const ptSqlRow = database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
const ptSql = ptSqlRow?.sql || '';
|
||||
if (ptSql && !ptSql.includes('arbiter_requested')) {
|
||||
database.exec(`
|
||||
CREATE TABLE paired_tasks_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
owner_agent_type TEXT,
|
||||
reviewer_agent_type TEXT,
|
||||
arbiter_agent_type TEXT,
|
||||
title TEXT,
|
||||
source_ref TEXT,
|
||||
plan_notes TEXT,
|
||||
review_requested_at TEXT,
|
||||
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
arbiter_verdict TEXT,
|
||||
arbiter_requested_at TEXT,
|
||||
completion_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
|
||||
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
||||
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
||||
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
||||
);
|
||||
INSERT INTO paired_tasks_new (
|
||||
id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type,
|
||||
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
|
||||
round_trip_count, status, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type,
|
||||
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
|
||||
round_trip_count, status, created_at, updated_at
|
||||
FROM paired_tasks;
|
||||
DROP TABLE paired_tasks;
|
||||
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||
ON paired_tasks(chat_jid, status, updated_at);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const column of [
|
||||
'owner_agent_type',
|
||||
'reviewer_agent_type',
|
||||
'arbiter_agent_type',
|
||||
]) {
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`,
|
||||
);
|
||||
}
|
||||
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`,
|
||||
);
|
||||
|
||||
for (const table of [
|
||||
'paired_executions',
|
||||
'paired_approvals',
|
||||
'paired_artifacts',
|
||||
'paired_events',
|
||||
]) {
|
||||
database.exec(`DROP TABLE IF EXISTS ${table}`);
|
||||
}
|
||||
|
||||
const pairedWsSqlRow = database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
const pairedWsSql = pairedWsSqlRow?.sql || '';
|
||||
if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) {
|
||||
database.exec(`DROP TABLE IF EXISTS paired_workspaces`);
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS paired_workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
workspace_dir TEXT NOT NULL,
|
||||
snapshot_source_dir TEXT,
|
||||
snapshot_ref TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ready',
|
||||
snapshot_refreshed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (role IN ('owner', 'reviewer')),
|
||||
CHECK (status IN ('ready', 'stale'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
|
||||
ON paired_workspaces(task_id, role);
|
||||
`);
|
||||
}
|
||||
|
||||
const pairedProjSqlRow = database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
const pairedProjSql = pairedProjSqlRow?.sql || '';
|
||||
if (pairedProjSql && pairedProjSql.includes('workspace_topology')) {
|
||||
database.exec(`DROP TABLE IF EXISTS paired_projects`);
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS paired_projects (
|
||||
chat_jid TEXT PRIMARY KEY,
|
||||
group_folder TEXT NOT NULL,
|
||||
canonical_work_dir TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
migrateSessionsTableToCompositePk(database, 'claude-code');
|
||||
|
||||
try {
|
||||
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
||||
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`,
|
||||
);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`,
|
||||
);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
|
||||
);
|
||||
database.exec(
|
||||
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
|
||||
);
|
||||
} catch {
|
||||
/* columns already exist */
|
||||
}
|
||||
|
||||
hooks.backfillStoredRoomSettings(database);
|
||||
if (tableHasColumn(database, 'channel_owner', 'owner_service_id')) {
|
||||
hooks.backfillChannelOwnerRoleMetadata(database);
|
||||
}
|
||||
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
|
||||
if (tableHasColumn(database, 'work_items', 'service_id')) {
|
||||
hooks.backfillWorkItemServiceShadows(database);
|
||||
}
|
||||
if (tableHasColumn(database, 'service_handoffs', 'source_service_id')) {
|
||||
hooks.backfillServiceHandoffServiceShadows(database);
|
||||
}
|
||||
if (tableHasColumn(database, 'paired_tasks', 'owner_service_id')) {
|
||||
hooks.backfillPairedTaskRoleMetadata(database);
|
||||
}
|
||||
|
||||
hooks.rebuildWorkItemsCanonicalSchema(database);
|
||||
dropLegacyServiceSessionsTable(database);
|
||||
hooks.rebuildChannelOwnerCanonicalSchema(database);
|
||||
hooks.rebuildPairedTasksCanonicalSchema(database);
|
||||
hooks.rebuildServiceHandoffsCanonicalSchema(database);
|
||||
}
|
||||
165
src/db/sessions.ts
Normal file
165
src/db/sessions.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { AgentType } from '../types.js';
|
||||
|
||||
export type ServiceShadowAgentTypeResolver = (
|
||||
serviceId: string,
|
||||
) => AgentType | undefined;
|
||||
|
||||
function hasLegacyServiceSessionsTable(database: Database): boolean {
|
||||
return Boolean(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'service_sessions'`,
|
||||
)
|
||||
.get(),
|
||||
);
|
||||
}
|
||||
|
||||
export function migrateSessionsTableToCompositePk(
|
||||
database: Database,
|
||||
defaultAgentType: AgentType,
|
||||
): void {
|
||||
const sessionCols = database
|
||||
.prepare('PRAGMA table_info(sessions)')
|
||||
.all() as Array<{ name: string }>;
|
||||
if (sessionCols.some((col) => col.name === 'agent_type')) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE sessions_new (
|
||||
group_folder TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL DEFAULT '${defaultAgentType}',
|
||||
session_id TEXT NOT NULL,
|
||||
PRIMARY KEY (group_folder, agent_type)
|
||||
);
|
||||
`);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO sessions_new (group_folder, agent_type, session_id)
|
||||
SELECT group_folder, ?, session_id FROM sessions`,
|
||||
)
|
||||
.run(defaultAgentType);
|
||||
database.exec(`
|
||||
DROP TABLE sessions;
|
||||
ALTER TABLE sessions_new RENAME TO sessions;
|
||||
`);
|
||||
}
|
||||
|
||||
export function getSessionFromDatabase(
|
||||
database: Database,
|
||||
groupFolder: string,
|
||||
agentType: string,
|
||||
): string | undefined {
|
||||
const row = database
|
||||
.prepare(
|
||||
'SELECT session_id FROM sessions WHERE group_folder = ? AND agent_type = ?',
|
||||
)
|
||||
.get(groupFolder, agentType) as { session_id: string } | undefined;
|
||||
return row?.session_id;
|
||||
}
|
||||
|
||||
export function setSessionInDatabase(
|
||||
database: Database,
|
||||
groupFolder: string,
|
||||
agentType: string,
|
||||
sessionId: string,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)',
|
||||
)
|
||||
.run(groupFolder, agentType, sessionId);
|
||||
}
|
||||
|
||||
export function deleteSessionFromDatabase(
|
||||
database: Database,
|
||||
groupFolder: string,
|
||||
agentType: string,
|
||||
): void {
|
||||
database
|
||||
.prepare('DELETE FROM sessions WHERE group_folder = ? AND agent_type = ?')
|
||||
.run(groupFolder, agentType);
|
||||
}
|
||||
|
||||
export function deleteAllSessionsForGroupFromDatabase(
|
||||
database: Database,
|
||||
groupFolder: string,
|
||||
): void {
|
||||
database
|
||||
.prepare('DELETE FROM sessions WHERE group_folder = ?')
|
||||
.run(groupFolder);
|
||||
}
|
||||
|
||||
export function getAllSessionsForAgentTypeFromDatabase(
|
||||
database: Database,
|
||||
agentType: string,
|
||||
): Record<string, string> {
|
||||
const rows = database
|
||||
.prepare(
|
||||
'SELECT group_folder, session_id FROM sessions WHERE agent_type = ?',
|
||||
)
|
||||
.all(agentType) as Array<{
|
||||
group_folder: string;
|
||||
session_id: string;
|
||||
}>;
|
||||
const result: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
result[row.group_folder] = row.session_id;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function backfillLegacyServiceSessions(
|
||||
database: Database,
|
||||
resolveAgentTypeFromServiceId: ServiceShadowAgentTypeResolver,
|
||||
): void {
|
||||
if (!hasLegacyServiceSessionsTable(database)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT group_folder, service_id, session_id
|
||||
FROM service_sessions`,
|
||||
)
|
||||
.all() as Array<{
|
||||
group_folder: string;
|
||||
service_id: string;
|
||||
session_id: string;
|
||||
}>;
|
||||
|
||||
const upsert = database.prepare(
|
||||
`INSERT OR IGNORE INTO sessions (group_folder, agent_type, session_id)
|
||||
VALUES (?, ?, ?)`,
|
||||
);
|
||||
|
||||
const tx = database.transaction(
|
||||
(
|
||||
sessionRows: Array<{
|
||||
group_folder: string;
|
||||
service_id: string;
|
||||
session_id: string;
|
||||
}>,
|
||||
) => {
|
||||
for (const row of sessionRows) {
|
||||
const agentType = resolveAgentTypeFromServiceId(row.service_id);
|
||||
if (!agentType) {
|
||||
continue;
|
||||
}
|
||||
upsert.run(row.group_folder, agentType, row.session_id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
tx(rows);
|
||||
}
|
||||
|
||||
export function dropLegacyServiceSessionsTable(database: Database): void {
|
||||
if (!hasLegacyServiceSessionsTable(database)) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(`DROP TABLE service_sessions`);
|
||||
}
|
||||
289
src/db/tasks.ts
Normal file
289
src/db/tasks.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { AgentType, ScheduledTask, TaskRunLog } from '../types.js';
|
||||
|
||||
export type CreateScheduledTaskInput = Omit<
|
||||
ScheduledTask,
|
||||
| 'last_run'
|
||||
| 'last_result'
|
||||
| 'agent_type'
|
||||
| 'ci_provider'
|
||||
| 'ci_metadata'
|
||||
| 'max_duration_ms'
|
||||
| 'status_message_id'
|
||||
| 'status_started_at'
|
||||
> & {
|
||||
agent_type?: AgentType | null;
|
||||
ci_provider?: ScheduledTask['ci_provider'];
|
||||
ci_metadata?: string | null;
|
||||
max_duration_ms?: number | null;
|
||||
status_message_id?: string | null;
|
||||
status_started_at?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskUpdates = Partial<
|
||||
Pick<
|
||||
ScheduledTask,
|
||||
| 'prompt'
|
||||
| 'schedule_type'
|
||||
| 'schedule_value'
|
||||
| 'next_run'
|
||||
| 'status'
|
||||
| 'suspended_until'
|
||||
| 'ci_metadata'
|
||||
>
|
||||
>;
|
||||
|
||||
export type ScheduledTaskStatusTrackingUpdates = Partial<
|
||||
Pick<ScheduledTask, 'status_message_id' | 'status_started_at'>
|
||||
>;
|
||||
|
||||
export function createTaskInDatabase(
|
||||
database: Database,
|
||||
task: CreateScheduledTaskInput,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, ci_provider, ci_metadata, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
task.id,
|
||||
task.group_folder,
|
||||
task.chat_jid,
|
||||
task.agent_type || 'claude-code',
|
||||
task.ci_provider ?? null,
|
||||
task.ci_metadata ?? null,
|
||||
task.max_duration_ms ?? null,
|
||||
task.status_message_id || null,
|
||||
task.status_started_at || null,
|
||||
task.prompt,
|
||||
task.schedule_type,
|
||||
task.schedule_value,
|
||||
task.context_mode || 'isolated',
|
||||
task.next_run,
|
||||
task.status,
|
||||
task.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTaskByIdFromDatabase(
|
||||
database: Database,
|
||||
id: string,
|
||||
): ScheduledTask | undefined {
|
||||
return database
|
||||
.prepare('SELECT * FROM scheduled_tasks WHERE id = ?')
|
||||
.get(id) as ScheduledTask | undefined;
|
||||
}
|
||||
|
||||
export function findDuplicateCiWatcherInDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
ciProvider: string,
|
||||
ciMetadata: string,
|
||||
): ScheduledTask | undefined {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT * FROM scheduled_tasks
|
||||
WHERE chat_jid = ? AND ci_provider = ? AND ci_metadata = ?
|
||||
AND status IN ('active', 'paused')
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(chatJid, ciProvider, ciMetadata) as ScheduledTask | undefined;
|
||||
}
|
||||
|
||||
export function getTasksForGroupFromDatabase(
|
||||
database: Database,
|
||||
groupFolder: string,
|
||||
agentType?: AgentType,
|
||||
): ScheduledTask[] {
|
||||
if (agentType) {
|
||||
return database
|
||||
.prepare(
|
||||
'SELECT * FROM scheduled_tasks WHERE group_folder = ? AND agent_type = ? ORDER BY created_at DESC',
|
||||
)
|
||||
.all(groupFolder, agentType) as ScheduledTask[];
|
||||
}
|
||||
|
||||
return database
|
||||
.prepare(
|
||||
'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC',
|
||||
)
|
||||
.all(groupFolder) as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function getAllTasksFromDatabase(
|
||||
database: Database,
|
||||
agentType?: AgentType,
|
||||
): ScheduledTask[] {
|
||||
if (agentType) {
|
||||
return database
|
||||
.prepare(
|
||||
'SELECT * FROM scheduled_tasks WHERE agent_type = ? ORDER BY created_at DESC',
|
||||
)
|
||||
.all(agentType) as ScheduledTask[];
|
||||
}
|
||||
|
||||
return database
|
||||
.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC')
|
||||
.all() as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function updateTaskInDatabase(
|
||||
database: Database,
|
||||
id: string,
|
||||
updates: ScheduledTaskUpdates,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
|
||||
if (updates.prompt !== undefined) {
|
||||
fields.push('prompt = ?');
|
||||
values.push(updates.prompt);
|
||||
}
|
||||
if (updates.schedule_type !== undefined) {
|
||||
fields.push('schedule_type = ?');
|
||||
values.push(updates.schedule_type);
|
||||
}
|
||||
if (updates.schedule_value !== undefined) {
|
||||
fields.push('schedule_value = ?');
|
||||
values.push(updates.schedule_value);
|
||||
}
|
||||
if (updates.next_run !== undefined) {
|
||||
fields.push('next_run = ?');
|
||||
values.push(updates.next_run);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
if (updates.suspended_until !== undefined) {
|
||||
fields.push('suspended_until = ?');
|
||||
values.push(updates.suspended_until);
|
||||
}
|
||||
if (updates.ci_metadata !== undefined) {
|
||||
fields.push('ci_metadata = ?');
|
||||
values.push(updates.ci_metadata);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
database
|
||||
.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`)
|
||||
.run(...values);
|
||||
}
|
||||
|
||||
export function updateTaskStatusTrackingInDatabase(
|
||||
database: Database,
|
||||
id: string,
|
||||
updates: ScheduledTaskStatusTrackingUpdates,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
|
||||
if (updates.status_message_id !== undefined) {
|
||||
fields.push('status_message_id = ?');
|
||||
values.push(updates.status_message_id);
|
||||
}
|
||||
if (updates.status_started_at !== undefined) {
|
||||
fields.push('status_started_at = ?');
|
||||
values.push(updates.status_started_at);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
database
|
||||
.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`)
|
||||
.run(...values);
|
||||
}
|
||||
|
||||
export function hasActiveCiWatcherForChatInDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): boolean {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT 1 FROM scheduled_tasks
|
||||
WHERE chat_jid = ? AND status = 'active' AND prompt LIKE '[BACKGROUND CI WATCH]%'
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(chatJid);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function getDueTasksFromDatabase(database: Database): ScheduledTask[] {
|
||||
const now = new Date().toISOString();
|
||||
return database
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM scheduled_tasks
|
||||
WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
|
||||
AND (suspended_until IS NULL OR suspended_until <= ?)
|
||||
ORDER BY next_run
|
||||
`,
|
||||
)
|
||||
.all(now, now) as ScheduledTask[];
|
||||
}
|
||||
|
||||
export function updateTaskAfterRunInDatabase(
|
||||
database: Database,
|
||||
id: string,
|
||||
nextRun: string | null,
|
||||
lastResult: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE scheduled_tasks
|
||||
SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END
|
||||
WHERE id = ?
|
||||
`,
|
||||
)
|
||||
.run(nextRun, now, lastResult, nextRun, id);
|
||||
}
|
||||
|
||||
export function logTaskRunInDatabase(
|
||||
database: Database,
|
||||
log: TaskRunLog,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
log.task_id,
|
||||
log.run_at,
|
||||
log.duration_ms,
|
||||
log.status,
|
||||
log.result,
|
||||
log.error,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecentConsecutiveErrorsFromDatabase(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
limit: number = 5,
|
||||
): string[] {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT status, error FROM task_run_logs
|
||||
WHERE task_id = ? ORDER BY run_at DESC LIMIT ?`,
|
||||
)
|
||||
.all(taskId, limit) as Array<{ status: string; error: string | null }>;
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.status !== 'error' || !row.error) break;
|
||||
errors.push(row.error);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
183
src/db/work-items.ts
Normal file
183
src/db/work-items.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { SERVICE_SESSION_SCOPE } from '../config.js';
|
||||
import { inferRoleFromServiceShadow } from '../role-service-shadow.js';
|
||||
import { AgentType, PairedRoomRole } from '../types.js';
|
||||
import { resolveWorkItemServiceShadow } from './legacy-rebuilds.js';
|
||||
|
||||
export interface WorkItem {
|
||||
id: number;
|
||||
group_folder: string;
|
||||
chat_jid: string;
|
||||
agent_type: AgentType;
|
||||
service_id: string;
|
||||
delivery_role?: PairedRoomRole | null;
|
||||
status: 'produced' | 'delivery_retry' | 'delivered';
|
||||
start_seq: number | null;
|
||||
end_seq: number | null;
|
||||
result_payload: string;
|
||||
delivery_attempts: number;
|
||||
delivery_message_id: string | null;
|
||||
last_error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
delivered_at: string | null;
|
||||
}
|
||||
|
||||
interface StoredWorkItemRow extends Omit<
|
||||
WorkItem,
|
||||
'service_id' | 'agent_type'
|
||||
> {
|
||||
agent_type: string;
|
||||
}
|
||||
|
||||
export interface CreateProducedWorkItemInput {
|
||||
group_folder: string;
|
||||
chat_jid: string;
|
||||
agent_type?: AgentType;
|
||||
delivery_role?: PairedRoomRole | null;
|
||||
start_seq: number | null;
|
||||
end_seq: number | null;
|
||||
result_payload: string;
|
||||
}
|
||||
|
||||
function normalizeStoredAgentType(
|
||||
agentType: string | null | undefined,
|
||||
): AgentType | undefined {
|
||||
if (agentType === 'claude-code' || agentType === 'codex') return agentType;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem {
|
||||
const agentType = normalizeStoredAgentType(row.agent_type) ?? 'claude-code';
|
||||
return {
|
||||
...row,
|
||||
agent_type: agentType,
|
||||
service_id: resolveWorkItemServiceShadow(agentType, row.delivery_role),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpenWorkItemFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
agentType: AgentType = 'claude-code',
|
||||
serviceId: string = SERVICE_SESSION_SCOPE,
|
||||
): WorkItem | undefined {
|
||||
const preferredRole = inferRoleFromServiceShadow(agentType, serviceId);
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM work_items
|
||||
WHERE chat_jid = ? AND agent_type = ?
|
||||
AND status IN ('produced', 'delivery_retry')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN ? IS NOT NULL AND delivery_role = ? THEN 0
|
||||
WHEN delivery_role IS NULL THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
id ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(chatJid, agentType, preferredRole, preferredRole) as
|
||||
| StoredWorkItemRow
|
||||
| undefined;
|
||||
return row ? hydrateWorkItemRow(row) : undefined;
|
||||
}
|
||||
|
||||
export function getOpenWorkItemForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): WorkItem | undefined {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM work_items
|
||||
WHERE chat_jid = ?
|
||||
AND status IN ('produced', 'delivery_retry')
|
||||
ORDER BY id ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(chatJid) as StoredWorkItemRow | undefined;
|
||||
return row ? hydrateWorkItemRow(row) : undefined;
|
||||
}
|
||||
|
||||
export function createProducedWorkItemInDatabase(
|
||||
database: Database,
|
||||
input: CreateProducedWorkItemInput,
|
||||
): WorkItem {
|
||||
const now = new Date().toISOString();
|
||||
const agentType = input.agent_type || 'claude-code';
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO work_items (
|
||||
group_folder,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
delivery_role,
|
||||
status,
|
||||
start_seq,
|
||||
end_seq,
|
||||
result_payload,
|
||||
delivery_attempts,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
input.group_folder,
|
||||
input.chat_jid,
|
||||
agentType,
|
||||
input.delivery_role ?? null,
|
||||
input.start_seq,
|
||||
input.end_seq,
|
||||
input.result_payload,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const lastId = (
|
||||
database.prepare('SELECT last_insert_rowid() as id').get() as { id: number }
|
||||
).id;
|
||||
return hydrateWorkItemRow(
|
||||
database
|
||||
.prepare('SELECT * FROM work_items WHERE id = ?')
|
||||
.get(lastId) as StoredWorkItemRow,
|
||||
);
|
||||
}
|
||||
|
||||
export function markWorkItemDeliveredInDatabase(
|
||||
database: Database,
|
||||
id: number,
|
||||
deliveryMessageId?: string | null,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE work_items
|
||||
SET status = 'delivered',
|
||||
delivered_at = ?,
|
||||
delivery_message_id = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(now, deliveryMessageId || null, now, id);
|
||||
}
|
||||
|
||||
export function markWorkItemDeliveryRetryInDatabase(
|
||||
database: Database,
|
||||
id: number,
|
||||
error: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE work_items
|
||||
SET status = 'delivery_retry',
|
||||
delivery_attempts = delivery_attempts + 1,
|
||||
last_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(error, now, id);
|
||||
}
|
||||
20
src/env.ts
20
src/env.ts
@@ -1,6 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// ── Internal cache ──────────────────────────────────────────────
|
||||
|
||||
@@ -12,8 +11,7 @@ function parseEnvFile(): Record<string, string> {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(envFile, 'utf-8');
|
||||
} catch (err) {
|
||||
logger.debug({ err }, '.env file not found, using defaults');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -31,7 +29,7 @@ function parseEnvFile(): Record<string, string> {
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (value) result[key] = value;
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -49,13 +47,13 @@ export function loadEnvFile(): void {
|
||||
*/
|
||||
export function getEnv(key: string): string | undefined {
|
||||
if (!_cache) loadEnvFile();
|
||||
return process.env[key] || _cache![key] || undefined;
|
||||
}
|
||||
|
||||
/** Force-reload the .env file (e.g. after token refresh writes new values). */
|
||||
export function reloadEnvFile(): void {
|
||||
_cache = null;
|
||||
loadEnvFile();
|
||||
if (Object.prototype.hasOwnProperty.call(process.env, key)) {
|
||||
return process.env[key];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(_cache!, key)) {
|
||||
return _cache![key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,10 @@ import { GroupQueue, type GroupRunContext } from './group-queue.js';
|
||||
|
||||
// Mock config to control concurrency limit
|
||||
vi.mock('./config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||
IS_TEST_ENV: true,
|
||||
LOG_LEVEL: 'info',
|
||||
MAX_CONCURRENT_AGENTS: 2,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import pino, { type Logger } from 'pino';
|
||||
import { ASSISTANT_NAME, IS_TEST_ENV, LOG_LEVEL } from './config.js';
|
||||
|
||||
const serviceName = (process.env.ASSISTANT_NAME || 'claude').toLowerCase();
|
||||
const isTestEnv =
|
||||
process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
|
||||
const serviceName = ASSISTANT_NAME.toLowerCase();
|
||||
|
||||
type LoggerGlobalState = typeof globalThis & {
|
||||
__ejclawLogger?: Logger;
|
||||
@@ -13,11 +12,11 @@ const globalState = globalThis as LoggerGlobalState;
|
||||
|
||||
function createRootLogger(): Logger {
|
||||
const baseOptions = {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
level: LOG_LEVEL,
|
||||
name: serviceName,
|
||||
};
|
||||
|
||||
if (isTestEnv) {
|
||||
if (IS_TEST_ENV) {
|
||||
return pino(baseOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./config.js')>('./config.js');
|
||||
return {
|
||||
...actual,
|
||||
REVIEWER_AGENT_TYPE: actual.REVIEWER_AGENT_TYPE ?? 'claude-code',
|
||||
ARBITER_AGENT_TYPE: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
resolveFollowUpDispatch,
|
||||
@@ -228,6 +238,46 @@ describe('message-runtime-rules', () => {
|
||||
expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId);
|
||||
});
|
||||
|
||||
it('resolves merge_ready execution target back to the owner/finalize path', () => {
|
||||
const resolution = resolveExecutionTarget({
|
||||
lease: baseLease,
|
||||
pairedTaskStatus: 'merge_ready',
|
||||
groupFolder: 'group-1',
|
||||
groupAgentType: 'claude-code',
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
inferredRole: 'owner',
|
||||
activeRole: 'owner',
|
||||
configuredAgentType: 'claude-code',
|
||||
effectiveAgentType: 'claude-code',
|
||||
sessionFolder: 'group-1',
|
||||
});
|
||||
expect(resolution.effectiveServiceId).toBe(
|
||||
resolveLeaseServiceId(baseLease, 'owner'),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves arbiter execution target from arbiter_requested task status', () => {
|
||||
const resolution = resolveExecutionTarget({
|
||||
lease: baseLease,
|
||||
pairedTaskStatus: 'arbiter_requested',
|
||||
groupFolder: 'group-1',
|
||||
groupAgentType: 'claude-code',
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
inferredRole: 'arbiter',
|
||||
activeRole: 'arbiter',
|
||||
configuredAgentType: 'claude-code',
|
||||
effectiveAgentType: 'claude-code',
|
||||
sessionFolder: 'group-1:arbiter',
|
||||
});
|
||||
expect(resolution.effectiveServiceId).toBe(
|
||||
resolveLeaseServiceId(baseLease, 'arbiter'),
|
||||
);
|
||||
});
|
||||
|
||||
it('honors an arbiter forced role only when arbiter is configured', () => {
|
||||
const resolution = resolveExecutionTarget({
|
||||
lease: {
|
||||
|
||||
@@ -346,6 +346,108 @@ describe('task scheduler', () => {
|
||||
expect(enqueueTask.mock.calls[0][1]).toBe('task-single-tick');
|
||||
});
|
||||
|
||||
it('marks one-off tasks completed after a successful scheduler run', async () => {
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
id: 'task-once-success-finalize',
|
||||
group_folder: 'shared-group',
|
||||
chat_jid: 'shared@g.us',
|
||||
agent_type: 'codex',
|
||||
prompt: 'finalize once task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: dueAt,
|
||||
context_mode: 'isolated',
|
||||
next_run: dueAt,
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
let execution: Promise<void> | null = null;
|
||||
const enqueueTask = vi.fn(
|
||||
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
execution = fn();
|
||||
return execution;
|
||||
},
|
||||
);
|
||||
|
||||
await runSchedulerTickOnce({
|
||||
serviceAgentType: 'codex',
|
||||
registeredGroups: () => ({
|
||||
'shared@g.us': {
|
||||
name: 'Shared',
|
||||
folder: 'shared-group',
|
||||
trigger: '@Codex',
|
||||
added_at: '2026-02-22T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
},
|
||||
}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage: async () => {},
|
||||
});
|
||||
await execution;
|
||||
|
||||
const task = getTaskById('task-once-success-finalize');
|
||||
expect(task?.status).toBe('completed');
|
||||
expect(task?.next_run).toBeNull();
|
||||
expect(task?.last_result).toBe('done');
|
||||
});
|
||||
|
||||
it('keeps interval tasks active and reschedules them after an execution error', async () => {
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
id: 'task-interval-error-retry',
|
||||
group_folder: 'shared-group',
|
||||
chat_jid: 'shared@g.us',
|
||||
agent_type: 'codex',
|
||||
prompt: 'retry interval task',
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '60000',
|
||||
context_mode: 'isolated',
|
||||
next_run: dueAt,
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
(runAgentProcessMock as any).mockResolvedValueOnce({
|
||||
status: 'error',
|
||||
error: 'scheduler boom',
|
||||
});
|
||||
|
||||
let execution: Promise<void> | null = null;
|
||||
const enqueueTask = vi.fn(
|
||||
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
execution = fn();
|
||||
return execution;
|
||||
},
|
||||
);
|
||||
|
||||
await runSchedulerTickOnce({
|
||||
serviceAgentType: 'codex',
|
||||
registeredGroups: () => ({
|
||||
'shared@g.us': {
|
||||
name: 'Shared',
|
||||
folder: 'shared-group',
|
||||
trigger: '@Codex',
|
||||
added_at: '2026-02-22T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
},
|
||||
}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage: async () => {},
|
||||
});
|
||||
await execution;
|
||||
|
||||
const task = getTaskById('task-interval-error-retry');
|
||||
expect(task?.status).toBe('active');
|
||||
expect(task?.last_result).toBe('Error: scheduler boom');
|
||||
expect(task?.next_run).not.toBe(dueAt);
|
||||
expect(task?.next_run).not.toBeNull();
|
||||
});
|
||||
|
||||
it('uses the reviewer bot identity for paired-room scheduled output', async () => {
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
|
||||
@@ -5,6 +5,8 @@ import os from 'os';
|
||||
import {
|
||||
ARBITER_AGENT_TYPE,
|
||||
ARBITER_MODEL_CONFIG,
|
||||
DEFAULT_CLAUDE_MODEL,
|
||||
DEFAULT_CODEX_MODEL,
|
||||
OWNER_AGENT_TYPE,
|
||||
OWNER_MODEL_CONFIG,
|
||||
REVIEWER_AGENT_TYPE,
|
||||
@@ -597,15 +599,13 @@ function buildModelConfigSection(): string {
|
||||
if (!role.agentType && role.label === 'Arbiter') continue;
|
||||
const type = role.agentType || '—';
|
||||
const defaultModel =
|
||||
type === 'codex'
|
||||
? process.env.CODEX_MODEL || 'codex'
|
||||
: process.env.CLAUDE_MODEL || 'claude';
|
||||
type === 'codex' ? DEFAULT_CODEX_MODEL : DEFAULT_CLAUDE_MODEL;
|
||||
const model = role.model || defaultModel;
|
||||
|
||||
// Show fallback status for claude-code roles when global failover is active
|
||||
const isFallback = failover.active && type === 'claude-code';
|
||||
if (isFallback) {
|
||||
const fallbackModel = process.env.CODEX_MODEL || 'codex';
|
||||
const fallbackModel = DEFAULT_CODEX_MODEL;
|
||||
lines.push(` **${role.label}** — codex \`${fallbackModel}\` (fallback)`);
|
||||
} else {
|
||||
lines.push(` **${role.label}** — ${type} \`${model}\``);
|
||||
|
||||
Reference in New Issue
Block a user