merge: integrate origin/main (406 commits) into live branch
Merge upstream's paired-room stabilization, dedicated message-runtime-loop, outbound delivery refactor (ipc-outbound-delivery / deliverFormattedCanonicalMessage), discord module split, dashboard rework, and migrations 015-018 into our branch. Conflict policy: prefer upstream for the heavily-refactored paired-room / message-runtime / db cluster (it supersedes our earlier loop band-aids), keep only orthogonal unique fixes: - codex bundled-JS launcher fix (codex-warmup.ts) - slot-aligned Claude+Codex usage primer (usage-primer.ts) re-wired into index.ts - MemoryHigh=3G cgroup bound, discord perms diagnostic, prompt docs - progress null-race + silent-failure publish guards (message-turn-controller.ts) Dropped (superseded by upstream or unwired): reviewer STEP_DONE/TASK_DONE loop band-aids, router markdown-escape override, register tribunal-default+git-init, restart-context rewindToSeq (unwired). Verified: typecheck clean, build clean, vitest shows zero new regressions vs the origin/main baseline (only pre-existing env-config failures remain).
This commit is contained in:
29
src/agent-attempt-retry.test.ts
Normal file
29
src/agent-attempt-retry.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAttemptRetryAction } from './agent-attempt-retry.js';
|
||||
|
||||
describe('resolveAttemptRetryAction', () => {
|
||||
it('uses the streamed Codex trigger message as the rotation message', () => {
|
||||
const errorMessage =
|
||||
'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header';
|
||||
|
||||
const action = resolveAttemptRetryAction({
|
||||
provider: 'codex',
|
||||
canRetryClaudeCredentials: false,
|
||||
canRetryCodex: true,
|
||||
attempt: {
|
||||
sawOutput: false,
|
||||
streamedTriggerReason: {
|
||||
reason: 'auth-expired',
|
||||
message: errorMessage,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'codex',
|
||||
trigger: { reason: 'auth-expired' },
|
||||
rotationMessage: errorMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { getErrorMessage } from './utils.js';
|
||||
export interface AttemptStreamedTrigger {
|
||||
reason: AgentTriggerReason;
|
||||
retryAfterMs?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AttemptRetryState {
|
||||
@@ -131,7 +132,10 @@ export function resolveAttemptRetryAction(args: {
|
||||
attempt: Pick<AttemptRetryState, 'sawOutput' | 'streamedTriggerReason'>;
|
||||
rotationMessage?: string | null;
|
||||
}): AttemptRetryAction {
|
||||
const normalizedRotationMessage = args.rotationMessage ?? undefined;
|
||||
const normalizedRotationMessage =
|
||||
args.rotationMessage ??
|
||||
args.attempt.streamedTriggerReason?.message ??
|
||||
undefined;
|
||||
|
||||
const claudeTrigger = resolveClaudeRetryTrigger({
|
||||
canRetryClaudeCredentials: args.canRetryClaudeCredentials,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyAgentError,
|
||||
classifyClaudeAuthError,
|
||||
classifyCodexAuthError,
|
||||
detectClaudeProviderFailureMessage,
|
||||
isClaudeOrgAccessDeniedMessage,
|
||||
shouldRotateClaudeToken,
|
||||
@@ -55,15 +56,54 @@ describe('agent-error-detection', () => {
|
||||
expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded');
|
||||
});
|
||||
|
||||
it('classifies Claude 500 API banners as overloaded provider failures', () => {
|
||||
const message =
|
||||
'API Error: 500 Internal server error. This is a server-side issue, usually temporary — try again in a moment. If it persists, check status.claude.com.';
|
||||
|
||||
expect(classifyAgentError(message)).toEqual({
|
||||
it('classifies Codex model capacity errors as overloaded', () => {
|
||||
expect(
|
||||
classifyAgentError(
|
||||
'Selected model is at capacity. Please try a different model.',
|
||||
),
|
||||
).toEqual({
|
||||
category: 'overloaded',
|
||||
reason: 'overloaded',
|
||||
});
|
||||
expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded');
|
||||
});
|
||||
|
||||
it('classifies Codex reused refresh-token errors as auth-expired', () => {
|
||||
expect(
|
||||
classifyCodexAuthError(
|
||||
'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.',
|
||||
),
|
||||
).toEqual({
|
||||
category: 'auth-expired',
|
||||
reason: 'auth-expired',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies a Codex auth-expired trigger reason as auth-expired', () => {
|
||||
expect(classifyCodexAuthError('auth-expired')).toEqual({
|
||||
category: 'auth-expired',
|
||||
reason: 'auth-expired',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not classify the internal Codex pool-unavailable sentinel as an auth failure', () => {
|
||||
expect(
|
||||
classifyCodexAuthError(
|
||||
'auth-expired: All Codex rotation accounts unavailable; re-auth required before launching Codex',
|
||||
),
|
||||
).toEqual({ category: 'none', reason: '' });
|
||||
expect(
|
||||
classifyCodexAuthError(
|
||||
'Codex rotation pool unavailable: all rotation accounts are currently dead, rate-limited, or locked',
|
||||
),
|
||||
).toEqual({ category: 'none', reason: '' });
|
||||
});
|
||||
|
||||
it('classifies Codex workspace credit exhaustion as rate-limit', () => {
|
||||
expect(classifyAgentError('Workspace out of credits')).toEqual({
|
||||
category: 'rate-limit',
|
||||
reason: '429',
|
||||
retryAfterMs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only Claude quota/auth reasons as Claude rotation reasons', () => {
|
||||
|
||||
@@ -231,6 +231,35 @@ const NONE: AgentErrorClassification = {
|
||||
reason: '',
|
||||
};
|
||||
|
||||
export function isCodexPoolUnavailableError(
|
||||
error: string | null | undefined,
|
||||
): boolean {
|
||||
if (!error) return false;
|
||||
return (
|
||||
/all\s+codex(?:\s+rotation)?\s+accounts(?:\s+are)?\s+unavailable/i.test(
|
||||
error,
|
||||
) || /codex\s+rotation\s+pool\s+unavailable/i.test(error)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTerminalCodexAccountFailure(
|
||||
error: string | null | undefined,
|
||||
): boolean {
|
||||
if (!error) return false;
|
||||
if (isCodexPoolUnavailableError(error)) return true;
|
||||
if (classifyCodexAuthError(error).category !== 'none') return true;
|
||||
const lower = error.toLowerCase();
|
||||
if (
|
||||
classifyAgentError(error).category === 'rate-limit' &&
|
||||
(lower.includes('workspace out of credits') ||
|
||||
lower.includes('out of credits') ||
|
||||
lower.includes('codex'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an agent error string into a category.
|
||||
* Handles patterns common to both Claude and Codex: 429, 503, network.
|
||||
@@ -249,6 +278,8 @@ export function classifyAgentError(
|
||||
lower.includes('429') ||
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('usage limit') ||
|
||||
lower.includes('out of credits') ||
|
||||
lower.includes('workspace out of credits') ||
|
||||
lower.includes('hit your limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('rate_limit')
|
||||
@@ -267,6 +298,8 @@ export function classifyAgentError(
|
||||
hasApi5xx ||
|
||||
lower.includes('503') ||
|
||||
lower.includes('overloaded') ||
|
||||
lower.includes('selected model is at capacity') ||
|
||||
lower.includes('model is at capacity') ||
|
||||
((lower.includes('502') || lower.includes('bad gateway')) &&
|
||||
(lower.includes('cloudflare') ||
|
||||
lower.includes('<html') ||
|
||||
@@ -333,14 +366,21 @@ export function classifyCodexAuthError(
|
||||
error: string | null | undefined,
|
||||
): AgentErrorClassification {
|
||||
if (!error) return NONE;
|
||||
if (isCodexPoolUnavailableError(error)) return NONE;
|
||||
const lower = error.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('auth-expired') ||
|
||||
lower.includes('auth expired') ||
|
||||
lower.includes('401') ||
|
||||
lower.includes('authentication_error') ||
|
||||
lower.includes('failed to authenticate') ||
|
||||
lower.includes('access token could not be refreshed') ||
|
||||
lower.includes('oauth token has expired') ||
|
||||
lower.includes('refresh token was already used') ||
|
||||
lower.includes('refresh your existing token') ||
|
||||
lower.includes('log out and sign in again') ||
|
||||
lower.includes('app_session_terminated') ||
|
||||
lower.includes('unauthorized')
|
||||
) {
|
||||
return { category: 'auth-expired', reason: 'auth-expired' };
|
||||
|
||||
@@ -53,10 +53,3 @@ export function hasAgentOutputPayload(output: {
|
||||
}
|
||||
return output.result !== null && output.result !== undefined;
|
||||
}
|
||||
|
||||
export function isSilentAgentOutput(_output: {
|
||||
output?: StructuredAgentOutput;
|
||||
result?: string | object | null;
|
||||
}): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
export {
|
||||
extractMarkdownImageAttachments,
|
||||
extractMediaAttachments,
|
||||
extractImageTagPaths,
|
||||
IMAGE_TAG_RE,
|
||||
IPC_CLOSE_SENTINEL,
|
||||
IPC_INPUT_SUBDIR,
|
||||
IPC_POLL_MS,
|
||||
normalizeAgentOutput,
|
||||
normalizeEjclawStructuredOutput,
|
||||
normalizePublicTextOutput,
|
||||
OUTPUT_END_MARKER,
|
||||
OUTPUT_START_MARKER,
|
||||
writeProtocolOutput,
|
||||
type NormalizedRunnerOutput,
|
||||
type NormalizedAgentOutput,
|
||||
type RunnerOutputAttachment,
|
||||
type RunnerOutputPhase,
|
||||
type RunnerOutputVerdict,
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EJCLAW_ENV } from 'ejclaw-runners-shared';
|
||||
|
||||
const { mockReadEnvFile, mockGetActiveCodexAuthPath } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockReadEnvFile,
|
||||
mockGetActiveCodexAuthPath,
|
||||
mockGetCodexAccountCount,
|
||||
mockClaimCodexAuthLease,
|
||||
mockFindCodexAccountIndexByAuthPath,
|
||||
} = vi.hoisted(() => ({
|
||||
mockReadEnvFile: vi.fn<() => Record<string, string>>(),
|
||||
mockGetActiveCodexAuthPath: vi.fn<() => string | null>(),
|
||||
mockGetCodexAccountCount: vi.fn<() => number>(),
|
||||
mockClaimCodexAuthLease: vi.fn<
|
||||
() => {
|
||||
authPath: string;
|
||||
accountIndex: number;
|
||||
release: () => void;
|
||||
} | null
|
||||
>(),
|
||||
mockFindCodexAccountIndexByAuthPath: vi.fn<() => number | null>(),
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
@@ -22,11 +39,14 @@ vi.mock('./config.js', () => ({
|
||||
|
||||
vi.mock('./env.js', () => ({
|
||||
readEnvFile: mockReadEnvFile,
|
||||
getEnv: vi.fn((key: string) => undefined),
|
||||
getEnv: vi.fn((_key: string) => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./codex-token-rotation.js', () => ({
|
||||
getActiveCodexAuthPath: mockGetActiveCodexAuthPath,
|
||||
getCodexAccountCount: mockGetCodexAccountCount,
|
||||
claimCodexAuthLease: mockClaimCodexAuthLease,
|
||||
findCodexAccountIndexByAuthPath: mockFindCodexAccountIndexByAuthPath,
|
||||
}));
|
||||
|
||||
vi.mock('./token-rotation.js', () => ({
|
||||
@@ -108,6 +128,30 @@ const group: RegisteredGroup = {
|
||||
agentType: 'codex',
|
||||
};
|
||||
|
||||
function writeSkill(dir: string, name: string): void {
|
||||
const skillDir = path.join(dir, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: ${name} skill\n---\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function resetRoutingMocks(): void {
|
||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
|
||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||
chat_jid: 'dc:test',
|
||||
owner_service_id: 'claude',
|
||||
reviewer_service_id: 'codex-main',
|
||||
arbiter_service_id: null,
|
||||
owner_failover_active: false,
|
||||
activated_at: null,
|
||||
reason: null,
|
||||
explicit: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('prepareGroupEnvironment codex auth handling', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
@@ -133,6 +177,12 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
||||
|
||||
mockReadEnvFile.mockReset();
|
||||
mockGetActiveCodexAuthPath.mockReset();
|
||||
mockGetCodexAccountCount.mockReset();
|
||||
mockGetCodexAccountCount.mockReturnValue(0);
|
||||
mockClaimCodexAuthLease.mockReset();
|
||||
mockClaimCodexAuthLease.mockReturnValue(null);
|
||||
mockFindCodexAccountIndexByAuthPath.mockReset();
|
||||
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -214,6 +264,84 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
||||
expect(auth).toEqual(rotatedAuth);
|
||||
});
|
||||
|
||||
it('fails fast instead of launching Codex without OAuth when rotation accounts exist but no lease is available', () => {
|
||||
const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
|
||||
fs.writeFileSync(
|
||||
rotatedAuthPath,
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { access_token: 'locked-token' },
|
||||
}),
|
||||
);
|
||||
mockGetCodexAccountCount.mockReturnValue(1);
|
||||
mockClaimCodexAuthLease.mockReturnValue(null);
|
||||
mockGetActiveCodexAuthPath.mockReturnValue(rotatedAuthPath);
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
|
||||
expect(() => prepareGroupEnvironment(group, false, 'dc:test')).toThrow(
|
||||
/Codex rotation pool unavailable/,
|
||||
);
|
||||
|
||||
const authPath = path.join(
|
||||
tempRoot,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'services',
|
||||
'codex-main',
|
||||
'.codex',
|
||||
'auth.json',
|
||||
);
|
||||
expect(fs.existsSync(authPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareGroupEnvironment prompt stacks', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
let previousOpenAiKey: string | undefined;
|
||||
let previousCodexOpenAiKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tempRoot);
|
||||
|
||||
process.env.EJ_TEST_ROOT = tempRoot;
|
||||
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
|
||||
previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
previousCodexOpenAiKey = process.env.CODEX_OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.CODEX_OPENAI_API_KEY;
|
||||
|
||||
fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
|
||||
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
mockReadEnvFile.mockReset();
|
||||
mockGetActiveCodexAuthPath.mockReset();
|
||||
mockGetCodexAccountCount.mockReset();
|
||||
mockGetCodexAccountCount.mockReturnValue(0);
|
||||
mockClaimCodexAuthLease.mockReset();
|
||||
mockClaimCodexAuthLease.mockReturnValue(null);
|
||||
mockFindCodexAccountIndexByAuthPath.mockReset();
|
||||
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
delete process.env.EJ_TEST_ROOT;
|
||||
delete process.env.EJ_TEST_HOME;
|
||||
if (previousOpenAiKey) process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||
else delete process.env.OPENAI_API_KEY;
|
||||
if (previousCodexOpenAiKey) {
|
||||
process.env.CODEX_OPENAI_API_KEY = previousCodexOpenAiKey;
|
||||
} else {
|
||||
delete process.env.CODEX_OPENAI_API_KEY;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the failover owner prompt pack for codex-review when it owns an explicit failover lease', () => {
|
||||
vi.mocked(config.isReviewService).mockReturnValue(true);
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
@@ -368,66 +496,314 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
||||
|
||||
expect(segments).toEqual(['platform prompt']);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies per-role codex model overrides when roomRole is supplied', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
describe('prepareGroupEnvironment Codex MCP room role env', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
|
||||
const groupWithByRole: RegisteredGroup = {
|
||||
...group,
|
||||
agentType: 'codex',
|
||||
agentConfig: {
|
||||
codexModel: 'gpt-5-sonnet',
|
||||
codexEffort: 'medium',
|
||||
codexModelByRole: { arbiter: 'gpt-5-opus' },
|
||||
codexEffortByRole: { arbiter: 'high' },
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tempRoot);
|
||||
|
||||
const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
|
||||
roomRole: 'arbiter',
|
||||
});
|
||||
expect(arbiter.env.CODEX_MODEL).toBe('gpt-5-opus');
|
||||
expect(arbiter.env.CODEX_EFFORT).toBe('high');
|
||||
process.env.EJ_TEST_ROOT = tempRoot;
|
||||
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
|
||||
fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
|
||||
|
||||
const owner = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
|
||||
roomRole: 'owner',
|
||||
});
|
||||
expect(owner.env.CODEX_MODEL).toBe('gpt-5-sonnet');
|
||||
expect(owner.env.CODEX_EFFORT).toBe('medium');
|
||||
|
||||
const noRole = prepareGroupEnvironment(groupWithByRole, false, 'dc:test');
|
||||
expect(noRole.env.CODEX_MODEL).toBe('gpt-5-sonnet');
|
||||
expect(noRole.env.CODEX_EFFORT).toBe('medium');
|
||||
mockReadEnvFile.mockReset();
|
||||
mockGetActiveCodexAuthPath.mockReset();
|
||||
mockGetCodexAccountCount.mockReset();
|
||||
mockGetCodexAccountCount.mockReturnValue(0);
|
||||
mockClaimCodexAuthLease.mockReset();
|
||||
mockClaimCodexAuthLease.mockReturnValue(null);
|
||||
mockFindCodexAccountIndexByAuthPath.mockReset();
|
||||
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
|
||||
resetRoutingMocks();
|
||||
});
|
||||
|
||||
it('applies per-role claude model overrides when roomRole is supplied', () => {
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
delete process.env.EJ_TEST_ROOT;
|
||||
delete process.env.EJ_TEST_HOME;
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeMcpServer(): void {
|
||||
const mcpServerPath = path.join(
|
||||
tempRoot,
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'ipc-mcp-stdio.js',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true });
|
||||
fs.writeFileSync(mcpServerPath, '// test mcp server\n');
|
||||
}
|
||||
|
||||
function readCodexConfigToml(): string {
|
||||
return fs.readFileSync(
|
||||
path.join(
|
||||
tempRoot,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'services',
|
||||
'codex-main',
|
||||
'.codex',
|
||||
'config.toml',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
it('writes paired owner role into the Codex MCP config env', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
writeMcpServer();
|
||||
|
||||
const groupWithByRole: RegisteredGroup = {
|
||||
...group,
|
||||
agentType: 'claude-code',
|
||||
agentConfig: {
|
||||
claudeModel: 'claude-sonnet-4-6',
|
||||
claudeEffort: 'medium',
|
||||
claudeModelByRole: { arbiter: 'claude-opus-4-7' },
|
||||
claudeEffortByRole: { arbiter: 'high' },
|
||||
},
|
||||
};
|
||||
|
||||
const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
|
||||
roomRole: 'arbiter',
|
||||
prepareGroupEnvironment(group, false, 'dc:test', {
|
||||
roomRole: 'owner',
|
||||
});
|
||||
expect(arbiter.env.CLAUDE_MODEL).toBe('claude-opus-4-7');
|
||||
expect(arbiter.env.CLAUDE_EFFORT).toBe('high');
|
||||
|
||||
const reviewer = prepareGroupEnvironment(
|
||||
groupWithByRole,
|
||||
expect(readCodexConfigToml()).toContain(`${EJCLAW_ENV.roomRole} = "owner"`);
|
||||
});
|
||||
|
||||
it('omits room role from non-paired Codex MCP config env', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
writeMcpServer();
|
||||
|
||||
prepareGroupEnvironment(group, true, 'dc:test');
|
||||
|
||||
expect(readCodexConfigToml()).not.toContain(EJCLAW_ENV.roomRole);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareGroupEnvironment room skill overrides', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-skills-'));
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tempRoot);
|
||||
process.env.EJ_TEST_ROOT = tempRoot;
|
||||
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
|
||||
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
|
||||
recursive: true,
|
||||
});
|
||||
mockReadEnvFile.mockReset();
|
||||
mockGetActiveCodexAuthPath.mockReset();
|
||||
mockGetCodexAccountCount.mockReset();
|
||||
mockGetCodexAccountCount.mockReturnValue(0);
|
||||
mockClaimCodexAuthLease.mockReset();
|
||||
mockClaimCodexAuthLease.mockReturnValue(null);
|
||||
mockFindCodexAccountIndexByAuthPath.mockReset();
|
||||
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
delete process.env.EJ_TEST_ROOT;
|
||||
delete process.env.EJ_TEST_HOME;
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('filters Claude session skills with room overrides at spawn time', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
const homeSkills = path.join(
|
||||
process.env.EJ_TEST_HOME!,
|
||||
'.claude',
|
||||
'skills',
|
||||
);
|
||||
const workDir = path.join(tempRoot, 'workdir');
|
||||
const runnerSkills = path.join(tempRoot, 'runners', 'skills');
|
||||
writeSkill(homeSkills, 'claude-keep');
|
||||
writeSkill(path.join(workDir, '.claude', 'skills'), 'workdir-keep');
|
||||
writeSkill(runnerSkills, 'runner-keep');
|
||||
writeSkill(runnerSkills, 'runner-off');
|
||||
|
||||
prepareGroupEnvironment(
|
||||
{ ...group, agentType: 'claude-code', workDir },
|
||||
false,
|
||||
'dc:test',
|
||||
{ roomRole: 'reviewer' },
|
||||
{
|
||||
skillOverrides: [
|
||||
{
|
||||
chatJid: 'dc:test',
|
||||
agentType: 'claude-code',
|
||||
skillScope: 'runner',
|
||||
skillName: 'runner-off',
|
||||
enabled: false,
|
||||
createdAt: '2026-05-04T00:00:00.000Z',
|
||||
updatedAt: '2026-05-04T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(reviewer.env.CLAUDE_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(reviewer.env.CLAUDE_EFFORT).toBe('medium');
|
||||
|
||||
const sessionSkills = path.join(
|
||||
tempRoot,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'services',
|
||||
'codex-main',
|
||||
'.claude',
|
||||
'skills',
|
||||
);
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'claude-keep'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'workdir-keep'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses a session-scoped Codex home when room overrides disable skills', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
const codexSkills = path.join(
|
||||
process.env.EJ_TEST_HOME!,
|
||||
'.agents',
|
||||
'skills',
|
||||
);
|
||||
const runnerSkills = path.join(tempRoot, 'runners', 'skills');
|
||||
writeSkill(codexSkills, 'codex-keep');
|
||||
writeSkill(codexSkills, 'codex-off');
|
||||
writeSkill(runnerSkills, 'runner-keep');
|
||||
writeSkill(runnerSkills, 'runner-off');
|
||||
|
||||
const prepared = prepareGroupEnvironment(group, false, 'dc:test', {
|
||||
skillOverrides: [
|
||||
{
|
||||
chatJid: 'dc:test',
|
||||
agentType: 'codex',
|
||||
skillScope: 'codex-user',
|
||||
skillName: 'codex-off',
|
||||
enabled: false,
|
||||
createdAt: '2026-05-04T00:00:00.000Z',
|
||||
updatedAt: '2026-05-04T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
chatJid: 'dc:test',
|
||||
agentType: 'codex',
|
||||
skillScope: 'runner',
|
||||
skillName: 'runner-off',
|
||||
enabled: false,
|
||||
createdAt: '2026-05-04T00:00:00.000Z',
|
||||
updatedAt: '2026-05-04T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessionRoot = path.join(
|
||||
tempRoot,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'services',
|
||||
'codex-main',
|
||||
);
|
||||
const sessionHome = path.join(sessionRoot, 'home');
|
||||
const sessionSkills = path.join(sessionHome, '.agents', 'skills');
|
||||
expect(prepared.env.HOME).toBe(sessionHome);
|
||||
expect(prepared.env.CODEX_HOME).toBe(path.join(sessionRoot, '.codex'));
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'codex-keep'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'codex-off'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(codexSkills, 'codex-off'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareGroupEnvironment Codex goals handling', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
let previousCodexGoals: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-goals-'));
|
||||
previousCwd = process.cwd();
|
||||
previousCodexGoals = process.env.CODEX_GOALS;
|
||||
process.chdir(tempRoot);
|
||||
process.env.EJ_TEST_ROOT = tempRoot;
|
||||
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
|
||||
delete process.env.CODEX_GOALS;
|
||||
|
||||
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
mockReadEnvFile.mockReset();
|
||||
mockGetActiveCodexAuthPath.mockReset();
|
||||
mockGetCodexAccountCount.mockReset();
|
||||
mockGetCodexAccountCount.mockReturnValue(0);
|
||||
mockClaimCodexAuthLease.mockReset();
|
||||
mockClaimCodexAuthLease.mockReturnValue(null);
|
||||
mockFindCodexAccountIndexByAuthPath.mockReset();
|
||||
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
|
||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
|
||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||
chat_jid: 'dc:test',
|
||||
owner_service_id: 'claude',
|
||||
reviewer_service_id: 'codex-main',
|
||||
arbiter_service_id: null,
|
||||
owner_failover_active: false,
|
||||
activated_at: null,
|
||||
reason: null,
|
||||
explicit: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
delete process.env.EJ_TEST_ROOT;
|
||||
delete process.env.EJ_TEST_HOME;
|
||||
if (previousCodexGoals) process.env.CODEX_GOALS = previousCodexGoals;
|
||||
else delete process.env.CODEX_GOALS;
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps Codex goals disabled by default and enables them only via opt-in config', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
|
||||
const defaultPrepared = prepareGroupEnvironment(group, false, 'dc:test');
|
||||
expect(defaultPrepared.env.CODEX_GOALS).toBeUndefined();
|
||||
|
||||
const enabledPrepared = prepareGroupEnvironment(
|
||||
{
|
||||
...group,
|
||||
agentConfig: {
|
||||
codexGoals: true,
|
||||
},
|
||||
},
|
||||
false,
|
||||
'dc:test',
|
||||
);
|
||||
expect(enabledPrepared.env.CODEX_GOALS).toBe('true');
|
||||
});
|
||||
|
||||
it('allows CODEX_GOALS env opt-in for Codex runner sessions', () => {
|
||||
mockReadEnvFile.mockReturnValue({
|
||||
CODEX_GOALS: 'true',
|
||||
});
|
||||
|
||||
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
|
||||
|
||||
expect(prepared.env.CODEX_GOALS).toBe('true');
|
||||
});
|
||||
|
||||
it('enables goals from host ~/.codex/config.toml [features]', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
const homedirSpy = vi
|
||||
.spyOn(os, 'homedir')
|
||||
.mockReturnValue(process.env.EJ_TEST_HOME!);
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'),
|
||||
'[features]\ngoals = true\n',
|
||||
);
|
||||
|
||||
try {
|
||||
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
|
||||
expect(prepared.env.CODEX_GOALS).toBe('true');
|
||||
} finally {
|
||||
homedirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -450,6 +826,12 @@ describe('prepareReadonlySessionEnvironment codex compatibility', () => {
|
||||
|
||||
mockReadEnvFile.mockReset();
|
||||
mockGetActiveCodexAuthPath.mockReset();
|
||||
mockGetCodexAccountCount.mockReset();
|
||||
mockGetCodexAccountCount.mockReturnValue(0);
|
||||
mockClaimCodexAuthLease.mockReset();
|
||||
mockClaimCodexAuthLease.mockReturnValue(null);
|
||||
mockFindCodexAccountIndexByAuthPath.mockReset();
|
||||
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -459,6 +841,37 @@ describe('prepareReadonlySessionEnvironment codex compatibility', () => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not claim a Codex auth lease for Claude read-only sessions', () => {
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
mockGetCodexAccountCount.mockReturnValue(1);
|
||||
const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
|
||||
fs.writeFileSync(rotatedAuthPath, '{"auth_mode":"chatgpt"}\n');
|
||||
const release = vi.fn();
|
||||
mockClaimCodexAuthLease.mockReturnValue({
|
||||
authPath: rotatedAuthPath,
|
||||
accountIndex: 0,
|
||||
release,
|
||||
});
|
||||
|
||||
const sessionDir = path.join(tempRoot, 'readonly-claude-session');
|
||||
const prepared = prepareReadonlySessionEnvironment({
|
||||
sessionDir,
|
||||
chatJid: 'dc:test',
|
||||
isMain: false,
|
||||
groupFolder: 'codex-test-group',
|
||||
agentType: 'claude-code',
|
||||
memoryBriefing: 'memory briefing',
|
||||
role: 'reviewer',
|
||||
});
|
||||
|
||||
expect(prepared.codexSessionAuth).toBeNull();
|
||||
expect(mockClaimCodexAuthLease).not.toHaveBeenCalled();
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(sessionDir, '.codex', 'auth.json'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('writes matching AGENTS.md and copies host codex auth/config into the role-scoped session', () => {
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
|
||||
@@ -486,7 +899,7 @@ command = "node"
|
||||
args = ["old-ipc.js"]
|
||||
|
||||
[mcp_servers.ejclaw.env]
|
||||
EJCLAW_IPC_DIR = "/old/ipc"
|
||||
${EJCLAW_ENV.ipcDir} = "/old/ipc"
|
||||
|
||||
[mcp_servers.other]
|
||||
command = "node"
|
||||
@@ -525,9 +938,10 @@ args = ["other.js"]
|
||||
expect(toml).toContain('model = "gpt-5.4"');
|
||||
expect(toml).toContain('[mcp_servers.other]');
|
||||
expect(toml).toContain('[mcp_servers.ejclaw]');
|
||||
expect(toml).toContain('EJCLAW_IPC_DIR = "/workspace/ipc"');
|
||||
expect(toml).toContain('EJCLAW_GROUP_FOLDER = "codex-test-group"');
|
||||
expect(toml).toContain('EJCLAW_WORK_DIR = "/workspace/project"');
|
||||
expect(toml).toContain(`${EJCLAW_ENV.ipcDir} = "/workspace/ipc"`);
|
||||
expect(toml).toContain(`${EJCLAW_ENV.groupFolder} = "codex-test-group"`);
|
||||
expect(toml).toContain(`${EJCLAW_ENV.roomRole} = "reviewer"`);
|
||||
expect(toml).toContain(`${EJCLAW_ENV.workDir} = "/workspace/project"`);
|
||||
expect(toml).not.toContain('old-ipc.js');
|
||||
expect(toml).not.toContain('"/old/ipc"');
|
||||
expect(toml.match(/\[mcp_servers\.ejclaw\]/g)).toHaveLength(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { EJCLAW_ENV } from 'ejclaw-runners-shared';
|
||||
|
||||
import {
|
||||
GROUPS_DIR,
|
||||
@@ -11,7 +12,15 @@ import {
|
||||
} from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { getActiveCodexAuthPath } from './codex-token-rotation.js';
|
||||
import {
|
||||
claimCodexAuthLease,
|
||||
findCodexAccountIndexByAuthPath,
|
||||
getActiveCodexAuthPath,
|
||||
getCodexAccountCount,
|
||||
type CodexAuthLease,
|
||||
} from './codex-token-rotation.js';
|
||||
import { readCodexFeatureFromFile } from './codex-config-features.js';
|
||||
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
|
||||
import {
|
||||
getConfiguredClaudeTokens,
|
||||
getCurrentToken,
|
||||
@@ -33,6 +42,7 @@ import {
|
||||
hasReviewerLease,
|
||||
} from './service-routing.js';
|
||||
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
|
||||
import type { StoredRoomSkillOverride } from './db/rooms.js';
|
||||
|
||||
// writeCodexApiKeyAuth removed — Codex uses OAuth only.
|
||||
// API key auth caused unintended billing.
|
||||
@@ -53,6 +63,72 @@ function syncDirectoryEntries(sources: string[], destination: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir';
|
||||
|
||||
interface SkillSyncSource {
|
||||
dir: string;
|
||||
scope: SkillSyncScope;
|
||||
}
|
||||
|
||||
function getDisabledSkillNamesByScope(
|
||||
overrides: StoredRoomSkillOverride[] | undefined,
|
||||
agentType: AgentType,
|
||||
): Map<SkillSyncScope, Set<string>> {
|
||||
const disabled = new Map<SkillSyncScope, Set<string>>();
|
||||
for (const override of overrides ?? []) {
|
||||
if (override.agentType !== agentType || override.enabled !== false)
|
||||
continue;
|
||||
if (
|
||||
override.skillScope !== 'codex-user' &&
|
||||
override.skillScope !== 'claude-user' &&
|
||||
override.skillScope !== 'runner'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const names = disabled.get(override.skillScope) ?? new Set<string>();
|
||||
names.add(override.skillName);
|
||||
disabled.set(override.skillScope, names);
|
||||
}
|
||||
return disabled;
|
||||
}
|
||||
|
||||
function hasDisabledSkillOverrides(
|
||||
overrides: StoredRoomSkillOverride[] | undefined,
|
||||
agentType: AgentType,
|
||||
): boolean {
|
||||
return (overrides ?? []).some(
|
||||
(override) =>
|
||||
override.agentType === agentType && override.enabled === false,
|
||||
);
|
||||
}
|
||||
|
||||
function syncRoomSkillDirectories(args: {
|
||||
sources: SkillSyncSource[];
|
||||
destination: string;
|
||||
agentType: AgentType;
|
||||
overrides?: StoredRoomSkillOverride[];
|
||||
}): void {
|
||||
const disabledNamesByScope = getDisabledSkillNamesByScope(
|
||||
args.overrides,
|
||||
args.agentType,
|
||||
);
|
||||
|
||||
fs.rmSync(args.destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(args.destination, { recursive: true });
|
||||
|
||||
for (const source of args.sources) {
|
||||
if (!fs.existsSync(source.dir)) continue;
|
||||
for (const entry of fs.readdirSync(source.dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (disabledNamesByScope.get(source.scope)?.has(entry.name)) continue;
|
||||
|
||||
const srcPath = path.join(source.dir, entry.name);
|
||||
const dstPath = path.join(args.destination, entry.name);
|
||||
fs.cpSync(srcPath, dstPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionalPromptFile(
|
||||
projectRoot: string,
|
||||
filename: string,
|
||||
@@ -63,44 +139,39 @@ function readOptionalPromptFile(
|
||||
return prompt || undefined;
|
||||
}
|
||||
|
||||
function ensureClaudeSessionSettings(groupSessionsDir: string): void {
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
if (fs.existsSync(settingsFile)) return;
|
||||
|
||||
fs.writeFileSync(
|
||||
settingsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
|
||||
function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
|
||||
const settingsFile = path.join(sessionDir, '.claude.json');
|
||||
if (fs.existsSync(settingsFile)) return;
|
||||
|
||||
fs.writeFileSync(settingsFile, '{}\n');
|
||||
}
|
||||
|
||||
function syncHostCodexSessionFiles(sessionCodexDir: string): void {
|
||||
export interface PreparedCodexSessionAuth {
|
||||
canonicalAuthPath: string;
|
||||
sessionAuthPath: string;
|
||||
accountIndex: number | null;
|
||||
lease?: CodexAuthLease;
|
||||
}
|
||||
|
||||
function syncHostCodexSessionFiles(
|
||||
sessionCodexDir: string,
|
||||
): PreparedCodexSessionAuth | null {
|
||||
const hostCodexDir = path.join(os.homedir(), '.codex');
|
||||
fs.mkdirSync(sessionCodexDir, { recursive: true });
|
||||
|
||||
const authDst = path.join(sessionCodexDir, 'auth.json');
|
||||
const rotatedAuthSrc = getActiveCodexAuthPath();
|
||||
const hasRotationAccounts = getCodexAccountCount() > 0;
|
||||
const lease = claimCodexAuthLease();
|
||||
const rotatedAuthSrc =
|
||||
lease?.authPath ?? (!hasRotationAccounts ? getActiveCodexAuthPath() : null);
|
||||
const fallbackAuthSrc = path.join(hostCodexDir, 'auth.json');
|
||||
const authSrc =
|
||||
rotatedAuthSrc && fs.existsSync(rotatedAuthSrc)
|
||||
? rotatedAuthSrc
|
||||
: path.join(hostCodexDir, 'auth.json');
|
||||
if (fs.existsSync(authSrc)) {
|
||||
: !hasRotationAccounts && fs.existsSync(fallbackAuthSrc)
|
||||
? fallbackAuthSrc
|
||||
: null;
|
||||
if (authSrc) {
|
||||
fs.copyFileSync(authSrc, authDst);
|
||||
} else if (fs.existsSync(authDst)) {
|
||||
fs.unlinkSync(authDst);
|
||||
@@ -113,6 +184,28 @@ function syncHostCodexSessionFiles(sessionCodexDir: string): void {
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!rotatedAuthSrc ||
|
||||
authSrc !== rotatedAuthSrc ||
|
||||
!fs.existsSync(authDst)
|
||||
) {
|
||||
lease?.release();
|
||||
if (hasRotationAccounts) {
|
||||
throw new Error(
|
||||
'Codex rotation pool unavailable: all rotation accounts are currently dead, rate-limited, or locked; re-auth or clear stale state before launching Codex',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalAuthPath: rotatedAuthSrc,
|
||||
sessionAuthPath: authDst,
|
||||
accountIndex:
|
||||
lease?.accountIndex ?? findCodexAccountIndexByAuthPath(rotatedAuthSrc),
|
||||
...(lease ? { lease } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function upsertEjclawMcpServerSection(args: {
|
||||
@@ -124,6 +217,7 @@ function upsertEjclawMcpServerSection(args: {
|
||||
groupFolder: string;
|
||||
isMain: boolean;
|
||||
agentType: AgentType;
|
||||
roomRole?: PairedRoomRole;
|
||||
workDir?: string;
|
||||
}): void {
|
||||
const stripEjclawMcpServerSections = (input: string): string => {
|
||||
@@ -160,19 +254,22 @@ function upsertEjclawMcpServerSection(args: {
|
||||
? fs.readFileSync(args.sessionConfigPath, 'utf-8')
|
||||
: '';
|
||||
toml = stripEjclawMcpServerSections(toml);
|
||||
const tomlEnvLine = (name: string, value: string): string =>
|
||||
`${name} = ${JSON.stringify(value)}`;
|
||||
const mcpSection = `
|
||||
[mcp_servers.ejclaw]
|
||||
command = "node"
|
||||
args = [${JSON.stringify(args.mcpServerPath)}]
|
||||
|
||||
[mcp_servers.ejclaw.env]
|
||||
EJCLAW_IPC_DIR = ${JSON.stringify(args.ipcDir)}
|
||||
EJCLAW_HOST_IPC_DIR = ${JSON.stringify(args.hostIpcDir)}
|
||||
EJCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)}
|
||||
EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.groupFolder)}
|
||||
EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
|
||||
EJCLAW_AGENT_TYPE = ${JSON.stringify(args.agentType)}
|
||||
${args.workDir ? `EJCLAW_WORK_DIR = ${JSON.stringify(args.workDir)}\n` : ''}
|
||||
${tomlEnvLine(EJCLAW_ENV.ipcDir, args.ipcDir)}
|
||||
${tomlEnvLine(EJCLAW_ENV.hostIpcDir, args.hostIpcDir)}
|
||||
${tomlEnvLine(EJCLAW_ENV.chatJid, args.chatJid)}
|
||||
${tomlEnvLine(EJCLAW_ENV.groupFolder, args.groupFolder)}
|
||||
${tomlEnvLine(EJCLAW_ENV.isMain, args.isMain ? '1' : '0')}
|
||||
${tomlEnvLine(EJCLAW_ENV.agentType, args.agentType)}
|
||||
${args.roomRole ? `${tomlEnvLine(EJCLAW_ENV.roomRole, args.roomRole)}\n` : ''}
|
||||
${args.workDir ? `${tomlEnvLine(EJCLAW_ENV.workDir, args.workDir)}\n` : ''}
|
||||
`;
|
||||
fs.writeFileSync(args.sessionConfigPath, toml.trimEnd() + '\n' + mcpSection);
|
||||
}
|
||||
@@ -212,18 +309,18 @@ function buildBaseRunnerEnv(args: {
|
||||
: currentPath,
|
||||
TZ: TIMEZONE,
|
||||
HOME: os.homedir(),
|
||||
EJCLAW_GROUP_DIR: args.groupDir,
|
||||
EJCLAW_IPC_DIR: args.groupIpcDir,
|
||||
EJCLAW_HOST_IPC_DIR: args.hostIpcDir,
|
||||
EJCLAW_GLOBAL_DIR: args.globalDir,
|
||||
...(args.group.workDir ? { EJCLAW_WORK_DIR: args.group.workDir } : {}),
|
||||
EJCLAW_CHAT_JID: args.chatJid,
|
||||
EJCLAW_GROUP_FOLDER: args.group.folder,
|
||||
EJCLAW_IS_MAIN: args.isMain ? '1' : '0',
|
||||
EJCLAW_AGENT_TYPE: args.agentType,
|
||||
[EJCLAW_ENV.groupDir]: args.groupDir,
|
||||
[EJCLAW_ENV.ipcDir]: args.groupIpcDir,
|
||||
[EJCLAW_ENV.hostIpcDir]: args.hostIpcDir,
|
||||
[EJCLAW_ENV.globalDir]: args.globalDir,
|
||||
...(args.group.workDir ? { [EJCLAW_ENV.workDir]: args.group.workDir } : {}),
|
||||
[EJCLAW_ENV.chatJid]: args.chatJid,
|
||||
[EJCLAW_ENV.groupFolder]: args.group.folder,
|
||||
[EJCLAW_ENV.isMain]: args.isMain ? '1' : '0',
|
||||
[EJCLAW_ENV.agentType]: args.agentType,
|
||||
CLAUDE_CONFIG_DIR: args.groupSessionsDir,
|
||||
...(args.runtimeTaskId
|
||||
? { EJCLAW_RUNTIME_TASK_ID: args.runtimeTaskId }
|
||||
? { [EJCLAW_ENV.runtimeTaskId]: args.runtimeTaskId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -232,7 +329,6 @@ function prepareClaudeEnvironment(args: {
|
||||
env: Record<string, string>;
|
||||
envVars: Record<string, string>;
|
||||
group: RegisteredGroup;
|
||||
roomRole?: PairedRoomRole;
|
||||
}): void {
|
||||
if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
|
||||
args.env.ANTHROPIC_API_KEY =
|
||||
@@ -280,15 +376,6 @@ function prepareClaudeEnvironment(args: {
|
||||
if (args.group.agentConfig?.claudeEffort) {
|
||||
args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort;
|
||||
}
|
||||
// Per-role overrides (paired room): take precedence over the flat values.
|
||||
if (args.roomRole) {
|
||||
const modelByRole =
|
||||
args.group.agentConfig?.claudeModelByRole?.[args.roomRole];
|
||||
if (modelByRole) args.env.CLAUDE_MODEL = modelByRole;
|
||||
const effortByRole =
|
||||
args.group.agentConfig?.claudeEffortByRole?.[args.roomRole];
|
||||
if (effortByRole) args.env.CLAUDE_EFFORT = effortByRole;
|
||||
}
|
||||
if (args.group.agentConfig?.claudeThinking) {
|
||||
args.env.CLAUDE_THINKING = args.group.agentConfig.claudeThinking;
|
||||
}
|
||||
@@ -308,39 +395,31 @@ function prepareCodexSessionEnvironment(args: {
|
||||
sessionRootDir: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
roomRole?: PairedRoomRole;
|
||||
isPairedRoom: boolean;
|
||||
useFailoverPromptPack: boolean;
|
||||
memoryBriefing?: string;
|
||||
roomRole?: PairedRoomRole;
|
||||
}): void {
|
||||
skillOverrides?: StoredRoomSkillOverride[];
|
||||
}): PreparedCodexSessionAuth | null {
|
||||
// API key auth intentionally removed — Codex uses OAuth only.
|
||||
// Never pass any API key to Codex child process to prevent API billing.
|
||||
delete args.env.OPENAI_API_KEY;
|
||||
delete args.env.CODEX_OPENAI_API_KEY;
|
||||
|
||||
// Per-role overrides (paired room) win over flat config which wins over env.
|
||||
const codexModelByRole = args.roomRole
|
||||
? args.group.agentConfig?.codexModelByRole?.[args.roomRole]
|
||||
: undefined;
|
||||
const codexModel =
|
||||
codexModelByRole ||
|
||||
args.group.agentConfig?.codexModel ||
|
||||
args.envVars.CODEX_MODEL ||
|
||||
process.env.CODEX_MODEL;
|
||||
if (codexModel) args.env.CODEX_MODEL = codexModel;
|
||||
|
||||
const codexEffortByRole = args.roomRole
|
||||
? args.group.agentConfig?.codexEffortByRole?.[args.roomRole]
|
||||
: undefined;
|
||||
const codexEffort =
|
||||
codexEffortByRole ||
|
||||
args.group.agentConfig?.codexEffort ||
|
||||
args.envVars.CODEX_EFFORT ||
|
||||
process.env.CODEX_EFFORT;
|
||||
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
|
||||
|
||||
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
|
||||
syncHostCodexSessionFiles(sessionCodexDir);
|
||||
const codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);
|
||||
|
||||
const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
|
||||
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
|
||||
@@ -357,24 +436,19 @@ function prepareCodexSessionEnvironment(args: {
|
||||
}
|
||||
}
|
||||
|
||||
const goalsFromConfig = readCodexFeatureFromFile(sessionConfigPath, 'goals');
|
||||
const codexGoals =
|
||||
args.group.agentConfig?.codexGoals ??
|
||||
(goalsFromConfig ||
|
||||
args.envVars.CODEX_GOALS === 'true' ||
|
||||
process.env.CODEX_GOALS === 'true');
|
||||
if (codexGoals) {
|
||||
args.env.CODEX_GOALS = 'true';
|
||||
} else {
|
||||
delete args.env.CODEX_GOALS;
|
||||
}
|
||||
|
||||
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
|
||||
const codexGlobalDir = path.join(GROUPS_DIR, 'global');
|
||||
const codexGlobalClaudeMdPath = path.join(codexGlobalDir, 'CLAUDE.md');
|
||||
const codexGlobalMemory =
|
||||
!args.isMain && fs.existsSync(codexGlobalClaudeMdPath)
|
||||
? fs.readFileSync(codexGlobalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const codexGroupClaudeMdPath = path.join(
|
||||
GROUPS_DIR,
|
||||
args.group.folder,
|
||||
'CLAUDE.md',
|
||||
);
|
||||
const codexGroupMemory =
|
||||
args.group.folder !== 'global' &&
|
||||
args.group.folder !== 'main' &&
|
||||
fs.existsSync(codexGroupClaudeMdPath)
|
||||
? fs.readFileSync(codexGroupClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const sessionAgents = (
|
||||
args.useFailoverPromptPack
|
||||
? [
|
||||
@@ -389,12 +463,9 @@ function prepareCodexSessionEnvironment(args: {
|
||||
'owner-common-paired-room.md',
|
||||
)
|
||||
: undefined,
|
||||
codexGlobalMemory,
|
||||
codexGroupMemory,
|
||||
args.memoryBriefing,
|
||||
]
|
||||
: [
|
||||
readOptionalPromptFile(args.projectRoot, 'owner-common-platform.md'),
|
||||
readPlatformPrompt('codex', args.projectRoot),
|
||||
args.isPairedRoom
|
||||
? readOptionalPromptFile(
|
||||
@@ -402,8 +473,6 @@ function prepareCodexSessionEnvironment(args: {
|
||||
'owner-common-paired-room.md',
|
||||
)
|
||||
: undefined,
|
||||
codexGlobalMemory,
|
||||
codexGroupMemory,
|
||||
args.memoryBriefing,
|
||||
]
|
||||
)
|
||||
@@ -418,15 +487,39 @@ function prepareCodexSessionEnvironment(args: {
|
||||
|
||||
// Codex reads skills from ~/.agents/skills/ (user-level) and
|
||||
// {workDir}/.agents/skills/ (project-level), NOT from .codex/skills/.
|
||||
// Sync to the user-level path so all Codex sessions can discover them.
|
||||
const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills');
|
||||
syncDirectoryEntries(
|
||||
[
|
||||
path.join(os.homedir(), '.claude', 'skills'),
|
||||
path.join(args.projectRoot, 'runners', 'skills'),
|
||||
],
|
||||
codexSkillsDir,
|
||||
);
|
||||
if (hasDisabledSkillOverrides(args.skillOverrides, 'codex')) {
|
||||
const sessionHomeDir = path.join(args.sessionRootDir, 'home');
|
||||
args.env.HOME = sessionHomeDir;
|
||||
syncRoomSkillDirectories({
|
||||
sources: [
|
||||
{
|
||||
dir: path.join(os.homedir(), '.claude', 'skills'),
|
||||
scope: 'codex-user',
|
||||
},
|
||||
{
|
||||
dir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
scope: 'codex-user',
|
||||
},
|
||||
{
|
||||
dir: path.join(args.projectRoot, 'runners', 'skills'),
|
||||
scope: 'runner',
|
||||
},
|
||||
],
|
||||
destination: path.join(sessionHomeDir, '.agents', 'skills'),
|
||||
agentType: 'codex',
|
||||
overrides: args.skillOverrides,
|
||||
});
|
||||
} else {
|
||||
// Preserve the historical global sync path when no room override exists.
|
||||
const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills');
|
||||
syncDirectoryEntries(
|
||||
[
|
||||
path.join(os.homedir(), '.claude', 'skills'),
|
||||
path.join(args.projectRoot, 'runners', 'skills'),
|
||||
],
|
||||
codexSkillsDir,
|
||||
);
|
||||
}
|
||||
|
||||
const mcpServerPath = path.join(
|
||||
args.projectRoot,
|
||||
@@ -439,14 +532,15 @@ function prepareCodexSessionEnvironment(args: {
|
||||
upsertEjclawMcpServerSection({
|
||||
sessionConfigPath,
|
||||
mcpServerPath,
|
||||
ipcDir: args.env.EJCLAW_IPC_DIR,
|
||||
hostIpcDir: args.env.EJCLAW_HOST_IPC_DIR,
|
||||
ipcDir: args.env[EJCLAW_ENV.ipcDir],
|
||||
hostIpcDir: args.env[EJCLAW_ENV.hostIpcDir],
|
||||
chatJid: args.chatJid,
|
||||
groupFolder: args.group.folder,
|
||||
isMain: args.isMain,
|
||||
agentType: args.group.agentType || 'claude-code',
|
||||
agentType: 'codex',
|
||||
roomRole: args.roomRole,
|
||||
workDir:
|
||||
args.env.EJCLAW_WORK_DIR || args.group.workDir || args.projectRoot,
|
||||
args.env[EJCLAW_ENV.workDir] || args.group.workDir || args.projectRoot,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,12 +549,19 @@ function prepareCodexSessionEnvironment(args: {
|
||||
delete args.env.ANTHROPIC_BASE_URL;
|
||||
delete args.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
args.env.CODEX_HOME = sessionCodexDir;
|
||||
return codexSessionAuth;
|
||||
}
|
||||
|
||||
export interface PreparedGroupEnvironment {
|
||||
env: Record<string, string>;
|
||||
groupDir: string;
|
||||
runnerDir: string;
|
||||
codexSessionAuth?: PreparedCodexSessionAuth | null;
|
||||
}
|
||||
|
||||
export interface PreparedReadonlySessionEnvironment {
|
||||
codexHomeDir?: string;
|
||||
codexSessionAuth?: PreparedCodexSessionAuth | null;
|
||||
}
|
||||
|
||||
export function prepareGroupEnvironment(
|
||||
@@ -471,12 +572,7 @@ export function prepareGroupEnvironment(
|
||||
memoryBriefing?: string;
|
||||
runtimeTaskId?: string;
|
||||
useTaskScopedSession?: boolean;
|
||||
/**
|
||||
* When provided, the runner consults `agentConfig.{claudeModel,claudeEffort,
|
||||
* codexModel,codexEffort}ByRole[roomRole]` and lets those entries override
|
||||
* the flat per-group values. Used by paired rooms to spend the expensive
|
||||
* model on a specific role (typically arbiter) only.
|
||||
*/
|
||||
skillOverrides?: StoredRoomSkillOverride[];
|
||||
roomRole?: PairedRoomRole;
|
||||
},
|
||||
): PreparedGroupEnvironment {
|
||||
@@ -503,12 +599,26 @@ export function prepareGroupEnvironment(
|
||||
const workDirClaude = group.workDir
|
||||
? path.join(group.workDir, '.claude')
|
||||
: null;
|
||||
const skillSources = [
|
||||
path.join(os.homedir(), '.claude', 'skills'),
|
||||
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
|
||||
path.join(projectRoot, 'runners', 'skills'),
|
||||
];
|
||||
syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills'));
|
||||
syncRoomSkillDirectories({
|
||||
sources: [
|
||||
{
|
||||
dir: path.join(os.homedir(), '.claude', 'skills'),
|
||||
scope: 'claude-user',
|
||||
},
|
||||
...(workDirClaude
|
||||
? [
|
||||
{
|
||||
dir: path.join(workDirClaude, 'skills'),
|
||||
scope: 'workdir' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
|
||||
],
|
||||
destination: path.join(groupSessionsDir, 'skills'),
|
||||
agentType: 'claude-code',
|
||||
overrides: options?.skillOverrides,
|
||||
});
|
||||
|
||||
const groupIpcDir = runtimeTaskId
|
||||
? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId)
|
||||
@@ -536,20 +646,10 @@ export function prepareGroupEnvironment(
|
||||
const ownerCommonPairedRoomPrompt = isPairedRoom
|
||||
? readOptionalPromptFile(projectRoot, 'owner-common-paired-room.md')
|
||||
: undefined;
|
||||
const claudePairedRoomPrompt = isPairedRoom
|
||||
? readPairedRoomPrompt('claude-code', projectRoot)
|
||||
: undefined;
|
||||
const globalClaudeMemory =
|
||||
!isMain && fs.existsSync(globalClaudeMdPath)
|
||||
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const groupClaudeMdPath = path.join(GROUPS_DIR, group.folder, 'CLAUDE.md');
|
||||
const groupClaudeMemory =
|
||||
group.folder !== 'global' &&
|
||||
group.folder !== 'main' &&
|
||||
fs.existsSync(groupClaudeMdPath)
|
||||
? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
// Owner CLAUDE.md: platform rules + owner paired room rules.
|
||||
// Reviewer paired room rules are NOT included — those belong to the
|
||||
// Read-only reviewer/arbiter session only (via prepareReadonlySessionEnvironment).
|
||||
@@ -558,7 +658,6 @@ export function prepareGroupEnvironment(
|
||||
claudePlatformPrompt,
|
||||
ownerCommonPairedRoomPrompt,
|
||||
globalClaudeMemory,
|
||||
groupClaudeMemory,
|
||||
options?.memoryBriefing,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
@@ -587,6 +686,7 @@ export function prepareGroupEnvironment(
|
||||
'CLAUDE_EFFORT',
|
||||
'CODEX_MODEL',
|
||||
'CODEX_EFFORT',
|
||||
'CODEX_GOALS',
|
||||
]);
|
||||
|
||||
const env = buildBaseRunnerEnv({
|
||||
@@ -603,8 +703,9 @@ export function prepareGroupEnvironment(
|
||||
runtimeTaskId,
|
||||
});
|
||||
|
||||
let codexSessionAuth: PreparedCodexSessionAuth | null = null;
|
||||
if (agentType === 'codex') {
|
||||
prepareCodexSessionEnvironment({
|
||||
codexSessionAuth = prepareCodexSessionEnvironment({
|
||||
env,
|
||||
envVars,
|
||||
projectRoot,
|
||||
@@ -613,21 +714,17 @@ export function prepareGroupEnvironment(
|
||||
sessionRootDir,
|
||||
chatJid,
|
||||
isMain,
|
||||
roomRole: options?.roomRole,
|
||||
isPairedRoom,
|
||||
useFailoverPromptPack: useCodexReviewFailoverPromptPack,
|
||||
memoryBriefing: options?.memoryBriefing,
|
||||
roomRole: options?.roomRole,
|
||||
skillOverrides: options?.skillOverrides,
|
||||
});
|
||||
} else {
|
||||
prepareClaudeEnvironment({
|
||||
env,
|
||||
envVars,
|
||||
group,
|
||||
roomRole: options?.roomRole,
|
||||
});
|
||||
prepareClaudeEnvironment({ env, envVars, group });
|
||||
}
|
||||
|
||||
return { env, groupDir, runnerDir };
|
||||
return { env, groupDir, runnerDir, codexSessionAuth };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -650,7 +747,8 @@ export function prepareReadonlySessionEnvironment(args: {
|
||||
ipcDir?: string;
|
||||
hostIpcDir?: string;
|
||||
workDir?: string;
|
||||
}): void {
|
||||
skillOverrides?: StoredRoomSkillOverride[];
|
||||
}): PreparedReadonlySessionEnvironment {
|
||||
const {
|
||||
sessionDir,
|
||||
chatJid,
|
||||
@@ -662,19 +760,50 @@ export function prepareReadonlySessionEnvironment(args: {
|
||||
ipcDir = '/workspace/ipc',
|
||||
hostIpcDir = ipcDir,
|
||||
workDir = '/workspace/project',
|
||||
skillOverrides,
|
||||
} = args;
|
||||
const projectRoot = process.cwd();
|
||||
let codexSessionAuth: PreparedCodexSessionAuth | null = null;
|
||||
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
ensureClaudeSessionSettings(sessionDir);
|
||||
ensureClaudeGlobalSettingsFile(sessionDir);
|
||||
|
||||
// Sync skills from host
|
||||
const skillSources = [
|
||||
path.join(os.homedir(), '.claude', 'skills'),
|
||||
path.join(projectRoot, 'runners', 'skills'),
|
||||
];
|
||||
syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills'));
|
||||
syncRoomSkillDirectories({
|
||||
sources: [
|
||||
{
|
||||
dir: path.join(os.homedir(), '.claude', 'skills'),
|
||||
scope: agentType === 'codex' ? 'codex-user' : 'claude-user',
|
||||
},
|
||||
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
|
||||
],
|
||||
destination: path.join(sessionDir, 'skills'),
|
||||
agentType,
|
||||
overrides: skillOverrides,
|
||||
});
|
||||
const codexHomeDir =
|
||||
agentType === 'codex' && hasDisabledSkillOverrides(skillOverrides, 'codex')
|
||||
? sessionDir
|
||||
: undefined;
|
||||
if (codexHomeDir) {
|
||||
syncRoomSkillDirectories({
|
||||
sources: [
|
||||
{
|
||||
dir: path.join(os.homedir(), '.claude', 'skills'),
|
||||
scope: 'codex-user',
|
||||
},
|
||||
{
|
||||
dir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
scope: 'codex-user',
|
||||
},
|
||||
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
|
||||
],
|
||||
destination: path.join(codexHomeDir, '.agents', 'skills'),
|
||||
agentType,
|
||||
overrides: skillOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
// Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter)
|
||||
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
|
||||
@@ -689,19 +818,11 @@ export function prepareReadonlySessionEnvironment(args: {
|
||||
!isMain && fs.existsSync(globalClaudeMdPath)
|
||||
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const groupClaudeMdPath = path.join(GROUPS_DIR, groupFolder, 'CLAUDE.md');
|
||||
const groupClaudeMemory =
|
||||
groupFolder !== 'global' &&
|
||||
groupFolder !== 'main' &&
|
||||
fs.existsSync(groupClaudeMdPath)
|
||||
? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
|
||||
const sessionClaudeMd = [
|
||||
claudePlatformPrompt,
|
||||
claudePairedRoomPrompt,
|
||||
globalClaudeMemory,
|
||||
groupClaudeMemory,
|
||||
memoryBriefing,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
@@ -711,31 +832,39 @@ export function prepareReadonlySessionEnvironment(args: {
|
||||
const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md');
|
||||
if (sessionClaudeMd) {
|
||||
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
|
||||
const sessionCodexDir = path.join(sessionDir, '.codex');
|
||||
syncHostCodexSessionFiles(sessionCodexDir);
|
||||
fs.writeFileSync(
|
||||
path.join(sessionCodexDir, 'AGENTS.md'),
|
||||
sessionClaudeMd + '\n',
|
||||
);
|
||||
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
|
||||
const mcpServerPath = path.join(
|
||||
projectRoot,
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'ipc-mcp-stdio.js',
|
||||
);
|
||||
if (fs.existsSync(mcpServerPath)) {
|
||||
upsertEjclawMcpServerSection({
|
||||
sessionConfigPath,
|
||||
mcpServerPath,
|
||||
ipcDir,
|
||||
hostIpcDir,
|
||||
chatJid,
|
||||
groupFolder,
|
||||
isMain,
|
||||
agentType,
|
||||
workDir,
|
||||
if (agentType === 'codex') {
|
||||
const sessionCodexDir = path.join(sessionDir, '.codex');
|
||||
codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);
|
||||
fs.writeFileSync(
|
||||
path.join(sessionCodexDir, 'AGENTS.md'),
|
||||
sessionClaudeMd + '\n',
|
||||
);
|
||||
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
|
||||
const mcpServerPath = path.join(
|
||||
projectRoot,
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'ipc-mcp-stdio.js',
|
||||
);
|
||||
if (fs.existsSync(mcpServerPath)) {
|
||||
upsertEjclawMcpServerSection({
|
||||
sessionConfigPath,
|
||||
mcpServerPath,
|
||||
ipcDir,
|
||||
hostIpcDir,
|
||||
chatJid,
|
||||
groupFolder,
|
||||
isMain,
|
||||
agentType,
|
||||
roomRole: role,
|
||||
workDir,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fs.rmSync(path.join(sessionDir, '.codex'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
logger.info(
|
||||
@@ -752,4 +881,5 @@ export function prepareReadonlySessionEnvironment(args: {
|
||||
} else if (fs.existsSync(sessionClaudeMdPath)) {
|
||||
fs.unlinkSync(sessionClaudeMdPath);
|
||||
}
|
||||
return { codexHomeDir, codexSessionAuth };
|
||||
}
|
||||
|
||||
99
src/agent-runner-process.test.ts
Normal file
99
src/agent-runner-process.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
||||
import { runSpawnedAgentProcess } from './agent-runner-process.js';
|
||||
import type { AgentInput, AgentOutput } from './agent-runner.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
function buildProcess(): ChildProcess {
|
||||
const proc = new EventEmitter() as ChildProcess & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
killed: boolean;
|
||||
};
|
||||
proc.stdout = new PassThrough();
|
||||
proc.stderr = new PassThrough();
|
||||
proc.killed = false;
|
||||
proc.kill = vi.fn(() => {
|
||||
proc.killed = true;
|
||||
return true;
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
function timed<T>(promise: Promise<T>): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('agent process did not resolve')), 100),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const group: RegisteredGroup = {
|
||||
name: 'Agent Runner Test',
|
||||
folder: 'agent-runner-test',
|
||||
trigger: '@Agent',
|
||||
added_at: '2026-01-01T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
};
|
||||
|
||||
const input: AgentInput = {
|
||||
prompt: 'run',
|
||||
groupFolder: 'agent-runner-test',
|
||||
chatJid: 'dc:test',
|
||||
runId: 'run-1',
|
||||
isMain: false,
|
||||
};
|
||||
|
||||
describe('runSpawnedAgentProcess', () => {
|
||||
let logsDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (logsDir) {
|
||||
fs.rmSync(logsDir, { recursive: true, force: true });
|
||||
logsDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves even when streamed output delivery rejects', async () => {
|
||||
logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-agent-runner-'));
|
||||
const proc = buildProcess();
|
||||
const onOutput = vi.fn<(_: AgentOutput) => Promise<void>>(async () => {
|
||||
throw new Error('delivery failed');
|
||||
});
|
||||
|
||||
const resultPromise = runSpawnedAgentProcess({
|
||||
proc,
|
||||
group,
|
||||
input,
|
||||
processName: 'test-agent',
|
||||
logsDir,
|
||||
startTime: Date.now(),
|
||||
onOutput,
|
||||
});
|
||||
|
||||
(proc.stdout as PassThrough).write(
|
||||
[
|
||||
OUTPUT_START_MARKER,
|
||||
JSON.stringify({ status: 'success', result: 'hello' }),
|
||||
OUTPUT_END_MARKER,
|
||||
].join('\n'),
|
||||
);
|
||||
proc.emit('close', 0, null);
|
||||
|
||||
await expect(timed(resultPromise)).resolves.toMatchObject({
|
||||
status: 'success',
|
||||
result: null,
|
||||
});
|
||||
expect(onOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@ interface RunSpawnedAgentProcessArgs {
|
||||
logsDir: string;
|
||||
startTime: number;
|
||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||
onTerminalStreamedOutputFlushed?: (output: AgentOutput) => void;
|
||||
}
|
||||
|
||||
function isTerminalStreamedOutput(output: AgentOutput): boolean {
|
||||
return (output.phase ?? 'final') !== 'progress';
|
||||
}
|
||||
|
||||
function parseLegacyAgentOutput(stdout: string): AgentOutput {
|
||||
@@ -39,6 +44,88 @@ function parseLegacyAgentOutput(stdout: string): AgentOutput {
|
||||
return JSON.parse(jsonLine) as AgentOutput;
|
||||
}
|
||||
|
||||
function logStreamedOutputDeliveryError(
|
||||
err: unknown,
|
||||
group: RegisteredGroup,
|
||||
input: AgentInput,
|
||||
): void {
|
||||
logger.warn(
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
error: getErrorMessage(err),
|
||||
},
|
||||
'Streamed agent output delivery failed',
|
||||
);
|
||||
}
|
||||
|
||||
function logStreamedAgentErrorOutput(
|
||||
output: AgentOutput,
|
||||
group: RegisteredGroup,
|
||||
input: AgentInput,
|
||||
): void {
|
||||
if (output.status !== 'error') return;
|
||||
logger.warn(
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
error: output.error,
|
||||
newSessionId: output.newSessionId,
|
||||
},
|
||||
'Streamed agent error output',
|
||||
);
|
||||
}
|
||||
|
||||
function chainStreamedOutputDelivery(args: {
|
||||
outputChain: Promise<void>;
|
||||
parsed: AgentOutput;
|
||||
onOutput: (output: AgentOutput) => Promise<void>;
|
||||
onTerminalStreamedOutputFlushed?: (output: AgentOutput) => void;
|
||||
group: RegisteredGroup;
|
||||
input: AgentInput;
|
||||
}): Promise<void> {
|
||||
return args.outputChain.then(async () => {
|
||||
try {
|
||||
await args.onOutput(args.parsed);
|
||||
if (isTerminalStreamedOutput(args.parsed)) {
|
||||
args.onTerminalStreamedOutputFlushed?.(args.parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
logStreamedOutputDeliveryError(err, args.group, args.input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function writeTimeoutLog(args: {
|
||||
logsDir: string;
|
||||
input: AgentInput;
|
||||
group: RegisteredGroup;
|
||||
processName: string;
|
||||
duration: number;
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
hadStreamingOutput: boolean;
|
||||
}): void {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
fs.writeFileSync(
|
||||
path.join(args.logsDir, `agent-${args.input.runId || 'adhoc'}-${ts}.log`),
|
||||
[
|
||||
`=== Agent Run Log (TIMEOUT) ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${args.group.name}`,
|
||||
`ChatJid: ${args.input.chatJid}`,
|
||||
`RunId: ${args.input.runId || 'n/a'}`,
|
||||
`Process: ${args.processName}`,
|
||||
`Duration: ${args.duration}ms`,
|
||||
`Exit Code: ${args.code}`,
|
||||
`Signal: ${args.signal}`,
|
||||
`Had Streaming Output: ${args.hadStreamingOutput}`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
export function runSpawnedAgentProcess(
|
||||
args: RunSpawnedAgentProcessArgs,
|
||||
): Promise<AgentOutput> {
|
||||
@@ -144,19 +231,16 @@ export function runSpawnedAgentProcess(
|
||||
}
|
||||
hadStreamingOutput = true;
|
||||
resetTimeout();
|
||||
if (parsed.status === 'error') {
|
||||
logger.warn(
|
||||
{
|
||||
group: group.name,
|
||||
chatJid: input.chatJid,
|
||||
runId: input.runId,
|
||||
error: parsed.error,
|
||||
newSessionId: parsed.newSessionId,
|
||||
},
|
||||
'Streamed agent error output',
|
||||
);
|
||||
}
|
||||
outputChain = outputChain.then(() => onOutput(parsed));
|
||||
logStreamedAgentErrorOutput(parsed, group, input);
|
||||
outputChain = chainStreamedOutputDelivery({
|
||||
outputChain,
|
||||
parsed,
|
||||
onOutput,
|
||||
onTerminalStreamedOutputFlushed:
|
||||
args.onTerminalStreamedOutputFlushed,
|
||||
group,
|
||||
input,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
@@ -215,22 +299,16 @@ export function runSpawnedAgentProcess(
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (timedOut) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
fs.writeFileSync(
|
||||
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
|
||||
[
|
||||
`=== Agent Run Log (TIMEOUT) ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`ChatJid: ${input.chatJid}`,
|
||||
`RunId: ${input.runId || 'n/a'}`,
|
||||
`Process: ${processName}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Signal: ${signal}`,
|
||||
`Had Streaming Output: ${hadStreamingOutput}`,
|
||||
].join('\n'),
|
||||
);
|
||||
writeTimeoutLog({
|
||||
logsDir,
|
||||
input,
|
||||
group,
|
||||
processName,
|
||||
duration,
|
||||
code,
|
||||
signal,
|
||||
hadStreamingOutput,
|
||||
});
|
||||
|
||||
if (hadStreamingOutput) {
|
||||
logger.info(
|
||||
|
||||
@@ -71,6 +71,10 @@ vi.mock('./service-routing.js', () => ({
|
||||
hasReviewerLease: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('./db.js', () => ({
|
||||
getStoredRoomSkillOverrides: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
// Create a controllable fake ChildProcess
|
||||
function createFakeProcess() {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
@@ -108,6 +112,7 @@ vi.mock('child_process', async () => {
|
||||
|
||||
import { runAgentProcess, AgentOutput } from './agent-runner.js';
|
||||
import * as agentRunnerEnvironment from './agent-runner-environment.js';
|
||||
import { getStoredRoomSkillOverrides } from './db.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
const testGroup: RegisteredGroup = {
|
||||
@@ -132,6 +137,72 @@ function emitOutputMarker(
|
||||
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
|
||||
}
|
||||
|
||||
function mockCodexLeaseEnvironment(releaseLease: () => void) {
|
||||
return vi
|
||||
.spyOn(agentRunnerEnvironment, 'prepareGroupEnvironment')
|
||||
.mockReturnValueOnce({
|
||||
env: {
|
||||
EJCLAW_IPC_DIR: '/tmp/ejclaw-test-data/ipc/test-group',
|
||||
},
|
||||
groupDir: '/tmp/ejclaw-test-groups/test-group',
|
||||
runnerDir: '/tmp/ejclaw-test-runners/codex-runner',
|
||||
codexSessionAuth: {
|
||||
canonicalAuthPath: '/tmp/codex-account/auth.json',
|
||||
sessionAuthPath: '/tmp/codex-session/auth.json',
|
||||
accountIndex: 0,
|
||||
lease: {
|
||||
accountIndex: 0,
|
||||
authPath: '/tmp/codex-account/auth.json',
|
||||
release: releaseLease,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCodexLeaseReleasedAfterFinalOutputFlush() {
|
||||
const releaseLease = vi.fn();
|
||||
const prepareGroupEnvironmentSpy = mockCodexLeaseEnvironment(releaseLease);
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const codexGroup: RegisteredGroup = {
|
||||
...testGroup,
|
||||
agentType: 'codex',
|
||||
};
|
||||
let resultPromise: Promise<AgentOutput> | undefined;
|
||||
|
||||
try {
|
||||
resultPromise = runAgentProcess(
|
||||
codexGroup,
|
||||
{
|
||||
...testInput,
|
||||
runId: 'run-codex-lease-final',
|
||||
},
|
||||
() => {},
|
||||
onOutput,
|
||||
);
|
||||
|
||||
emitOutputMarker(fakeProc, {
|
||||
status: 'success',
|
||||
result: 'TASK_DONE\n작업 완료',
|
||||
phase: 'final',
|
||||
newSessionId: 'codex-session-lease-final',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(onOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
result: 'TASK_DONE\n작업 완료',
|
||||
phase: 'final',
|
||||
}),
|
||||
);
|
||||
expect(releaseLease).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fakeProc.emit('close', 0);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await resultPromise?.catch(() => undefined);
|
||||
prepareGroupEnvironmentSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent-runner timeout behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -152,23 +223,18 @@ describe('agent-runner timeout behavior', () => {
|
||||
onOutput,
|
||||
);
|
||||
|
||||
// Emit output with a result
|
||||
emitOutputMarker(fakeProc, {
|
||||
status: 'success',
|
||||
result: 'Here is my response',
|
||||
newSessionId: 'session-123',
|
||||
});
|
||||
|
||||
// Let output processing settle
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
|
||||
await vi.advanceTimersByTimeAsync(1830000);
|
||||
|
||||
// Emit close event (as if agent was stopped by the timeout)
|
||||
fakeProc.emit('close', 137);
|
||||
|
||||
// Let the promise resolve
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const result = await resultPromise;
|
||||
@@ -188,10 +254,8 @@ describe('agent-runner timeout behavior', () => {
|
||||
onOutput,
|
||||
);
|
||||
|
||||
// No output emitted — fire the hard timeout
|
||||
await vi.advanceTimersByTimeAsync(1830000);
|
||||
|
||||
// Emit close event
|
||||
fakeProc.emit('close', 137);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
@@ -211,7 +275,6 @@ describe('agent-runner timeout behavior', () => {
|
||||
onOutput,
|
||||
);
|
||||
|
||||
// Emit output
|
||||
emitOutputMarker(fakeProc, {
|
||||
status: 'success',
|
||||
result: 'Done',
|
||||
@@ -220,7 +283,6 @@ describe('agent-runner timeout behavior', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Normal exit (no timeout)
|
||||
fakeProc.emit('close', 0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
@@ -230,6 +292,10 @@ describe('agent-runner timeout behavior', () => {
|
||||
expect(result.newSessionId).toBe('session-456');
|
||||
});
|
||||
|
||||
it('releases Codex auth lease after final streamed output flushes even before process close', async () => {
|
||||
await expectCodexLeaseReleasedAfterFinalOutputFlush();
|
||||
});
|
||||
|
||||
it('preserves streamed progress phase metadata', async () => {
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const resultPromise = runAgentProcess(
|
||||
@@ -271,6 +337,18 @@ describe('agent-runner timeout behavior', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-runner environment wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
fakeProc = createFakeProcess();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes the actual chat JID into codex runner MCP env', async () => {
|
||||
vi.useRealTimers();
|
||||
@@ -408,6 +486,55 @@ describe('agent-runner timeout behavior', () => {
|
||||
expect(spawnEnv?.CODEX_HOME).toBe('/tmp/host-reviewer-session/.codex');
|
||||
});
|
||||
|
||||
it('releases the initial Codex auth lease when read-only session preparation fails', async () => {
|
||||
vi.useRealTimers();
|
||||
fakeProc = createFakeProcess();
|
||||
|
||||
const releaseLease = vi.fn();
|
||||
const prepareGroupEnvironmentSpy = mockCodexLeaseEnvironment(releaseLease);
|
||||
const readonlyError = new Error('Codex rotation pool unavailable');
|
||||
const prepareReadonlySessionEnvironmentSpy = vi
|
||||
.spyOn(agentRunnerEnvironment, 'prepareReadonlySessionEnvironment')
|
||||
.mockImplementationOnce(() => {
|
||||
throw readonlyError;
|
||||
});
|
||||
const codexGroup: RegisteredGroup = {
|
||||
...testGroup,
|
||||
agentType: 'codex',
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runAgentProcess(
|
||||
codexGroup,
|
||||
{
|
||||
...testInput,
|
||||
roomRoleContext: {
|
||||
serviceId: 'codex-review',
|
||||
role: 'reviewer',
|
||||
ownerServiceId: 'codex-main',
|
||||
reviewerServiceId: 'codex-review',
|
||||
failoverOwner: false,
|
||||
},
|
||||
},
|
||||
() => {},
|
||||
async () => {},
|
||||
{
|
||||
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
|
||||
CLAUDE_CONFIG_DIR: '/tmp/host-reviewer-session',
|
||||
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(readonlyError);
|
||||
|
||||
expect(prepareReadonlySessionEnvironmentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(releaseLease).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
prepareReadonlySessionEnvironmentSpy.mockRestore();
|
||||
prepareGroupEnvironmentSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes roomRoleContext into the runner stdin payload', async () => {
|
||||
vi.useRealTimers();
|
||||
fakeProc = createFakeProcess();
|
||||
@@ -629,6 +756,18 @@ OUROBOROS_LLM_BACKEND = "codex"
|
||||
expect(toml).toContain('OUROBOROS_AGENT_RUNTIME = "codex"');
|
||||
expect(toml).toContain('[mcp_servers.ejclaw]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-runner output flushing', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
fakeProc = createFakeProcess();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for queued streamed output before resolving an error exit', async () => {
|
||||
let releaseOutputs: (() => void) | undefined;
|
||||
@@ -680,3 +819,110 @@ OUROBOROS_LLM_BACKEND = "codex"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-runner room skill overrides', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]);
|
||||
fakeProc = createFakeProcess();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes room skill overrides into runner environment setup', async () => {
|
||||
const skillOverride = {
|
||||
chatJid: testInput.chatJid,
|
||||
agentType: 'claude-code' as const,
|
||||
skillScope: 'runner',
|
||||
skillName: 'runner-off',
|
||||
enabled: false,
|
||||
createdAt: '2026-05-04T00:00:00.000Z',
|
||||
updatedAt: '2026-05-04T00:00:00.000Z',
|
||||
};
|
||||
vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([skillOverride]);
|
||||
const prepareGroupEnvironmentSpy = vi.spyOn(
|
||||
agentRunnerEnvironment,
|
||||
'prepareGroupEnvironment',
|
||||
);
|
||||
|
||||
const resultPromise = runAgentProcess(
|
||||
testGroup,
|
||||
testInput,
|
||||
() => {},
|
||||
async () => {},
|
||||
);
|
||||
|
||||
fakeProc.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe('success');
|
||||
expect(getStoredRoomSkillOverrides).toHaveBeenCalledWith(testInput.chatJid);
|
||||
expect(prepareGroupEnvironmentSpy).toHaveBeenCalledWith(
|
||||
testGroup,
|
||||
testInput.isMain,
|
||||
testInput.chatJid,
|
||||
expect.objectContaining({
|
||||
skillOverrides: [skillOverride],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-runner Codex goals', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]);
|
||||
fakeProc = createFakeProcess();
|
||||
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
||||
const str = String(p);
|
||||
return (
|
||||
str.includes('dist/index.js') ||
|
||||
str.includes('dist/ipc-mcp-stdio.js') ||
|
||||
str.endsWith('/.codex/config.toml')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes Codex goal opt-in config through env and runner stdin payload', async () => {
|
||||
let stdinPayload = '';
|
||||
fakeProc.stdin.on('data', (chunk) => {
|
||||
stdinPayload += chunk.toString();
|
||||
});
|
||||
|
||||
const codexGroup: RegisteredGroup = {
|
||||
...testGroup,
|
||||
agentType: 'codex',
|
||||
agentConfig: {
|
||||
codexGoals: true,
|
||||
},
|
||||
};
|
||||
|
||||
const resultPromise = runAgentProcess(
|
||||
codexGroup,
|
||||
testInput,
|
||||
() => {},
|
||||
async () => {},
|
||||
);
|
||||
|
||||
fakeProc.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
expect(JSON.parse(stdinPayload)).toMatchObject({
|
||||
prompt: 'Hello',
|
||||
codexGoals: true,
|
||||
});
|
||||
|
||||
const spawnEnv = vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(spawnEnv?.CODEX_GOALS).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@ import { ChildProcess, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { isUnsafeHostPairedModeEnabled } from 'ejclaw-runners-shared';
|
||||
import {
|
||||
EJCLAW_ENV,
|
||||
isUnsafeHostPairedModeEnabled,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
/**
|
||||
* Agent Process Runner for EJClaw
|
||||
@@ -11,16 +14,19 @@ import { isUnsafeHostPairedModeEnabled } from 'ejclaw-runners-shared';
|
||||
import {
|
||||
prepareReadonlySessionEnvironment,
|
||||
prepareGroupEnvironment,
|
||||
type PreparedCodexSessionAuth,
|
||||
} from './agent-runner-environment.js';
|
||||
import { syncCodexSessionAuthBack } from './codex-token-rotation.js';
|
||||
import { runSpawnedAgentProcess } from './agent-runner-process.js';
|
||||
import { getEnv } from './env.js';
|
||||
import { getStoredRoomSkillOverrides } from './db.js';
|
||||
export {
|
||||
type AvailableGroup,
|
||||
writeGroupsSnapshot,
|
||||
writeTasksSnapshot,
|
||||
} from './agent-runner-snapshot.js';
|
||||
import { logger } from './logger.js';
|
||||
import { AgentType, RegisteredGroup, RoomRoleContext } from './types.js';
|
||||
import { RegisteredGroup, RoomRoleContext } from './types.js';
|
||||
import type { StoredRoomSkillOverride } from './db/rooms.js';
|
||||
|
||||
export interface AgentInput {
|
||||
prompt: string;
|
||||
@@ -35,6 +41,7 @@ export interface AgentInput {
|
||||
useTaskScopedSession?: boolean;
|
||||
assistantName?: string;
|
||||
agentType?: 'claude-code' | 'codex';
|
||||
codexGoals?: boolean;
|
||||
roomRoleContext?: RoomRoleContext;
|
||||
}
|
||||
|
||||
@@ -48,6 +55,68 @@ export interface AgentOutput {
|
||||
agentDone?: boolean;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
compaction?: {
|
||||
completed: boolean;
|
||||
trigger?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function readRoomSkillOverridesForRunner(
|
||||
chatJid: string,
|
||||
): StoredRoomSkillOverride[] {
|
||||
try {
|
||||
return getStoredRoomSkillOverrides(chatJid);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
chatJid,
|
||||
},
|
||||
'Failed to read room skill overrides; falling back to default skills',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function releaseCodexAuthSession(
|
||||
auth: PreparedCodexSessionAuth | null | undefined,
|
||||
): void {
|
||||
auth?.lease?.release();
|
||||
}
|
||||
|
||||
function finalizeCodexAuthSession(
|
||||
auth: PreparedCodexSessionAuth | null | undefined,
|
||||
): void {
|
||||
if (!auth) return;
|
||||
try {
|
||||
syncCodexSessionAuthBack({
|
||||
canonicalAuthPath: auth.canonicalAuthPath,
|
||||
sessionAuthPath: auth.sessionAuthPath,
|
||||
accountIndex: auth.accountIndex,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
canonicalAuthPath: auth.canonicalAuthPath,
|
||||
accountIndex: auth.accountIndex,
|
||||
},
|
||||
'Failed to sync Codex session auth back to canonical slot',
|
||||
);
|
||||
} finally {
|
||||
auth.lease?.release();
|
||||
}
|
||||
}
|
||||
|
||||
function createCodexAuthSessionFinalizer(
|
||||
getAuth: () => PreparedCodexSessionAuth | null | undefined,
|
||||
): () => void {
|
||||
let finalized = false;
|
||||
return () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
finalizeCodexAuthSession(getAuth());
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAgentProcess(
|
||||
@@ -65,17 +134,16 @@ export async function runAgentProcess(
|
||||
|
||||
// ── Host process mode (owner) ───────────────────────────────────
|
||||
const startTime = Date.now();
|
||||
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||
group,
|
||||
input.isMain,
|
||||
input.chatJid,
|
||||
{
|
||||
memoryBriefing: input.memoryBriefing,
|
||||
runtimeTaskId: input.runtimeTaskId,
|
||||
useTaskScopedSession: input.useTaskScopedSession,
|
||||
roomRole: input.roomRoleContext?.role,
|
||||
},
|
||||
);
|
||||
const skillOverrides = readRoomSkillOverridesForRunner(input.chatJid);
|
||||
const prepared = prepareGroupEnvironment(group, input.isMain, input.chatJid, {
|
||||
memoryBriefing: input.memoryBriefing,
|
||||
runtimeTaskId: input.runtimeTaskId,
|
||||
useTaskScopedSession: input.useTaskScopedSession,
|
||||
skillOverrides,
|
||||
roomRole: input.roomRoleContext?.role,
|
||||
});
|
||||
const { env, groupDir, runnerDir } = prepared;
|
||||
let codexSessionAuth = prepared.codexSessionAuth;
|
||||
|
||||
// Apply env overrides (caller-provided)
|
||||
if (envOverrides) {
|
||||
@@ -89,29 +157,48 @@ export async function runAgentProcess(
|
||||
(input.roomRoleContext?.role === 'reviewer' ||
|
||||
input.roomRoleContext?.role === 'arbiter')
|
||||
) {
|
||||
prepareReadonlySessionEnvironment({
|
||||
sessionDir: envOverrides.CLAUDE_CONFIG_DIR,
|
||||
chatJid: input.chatJid,
|
||||
isMain: input.isMain,
|
||||
groupFolder: group.folder,
|
||||
agentType: group.agentType || 'claude-code',
|
||||
memoryBriefing: input.memoryBriefing,
|
||||
role: input.roomRoleContext.role,
|
||||
ipcDir: env.EJCLAW_IPC_DIR,
|
||||
hostIpcDir: env.EJCLAW_HOST_IPC_DIR,
|
||||
workDir: envOverrides.EJCLAW_WORK_DIR || env.EJCLAW_WORK_DIR,
|
||||
});
|
||||
if ((group.agentType || 'claude-code') === 'codex') {
|
||||
const isCodexGroup = (group.agentType || 'claude-code') === 'codex';
|
||||
const previousCodexSessionAuth = codexSessionAuth;
|
||||
let readonlySession: ReturnType<typeof prepareReadonlySessionEnvironment>;
|
||||
try {
|
||||
readonlySession = prepareReadonlySessionEnvironment({
|
||||
sessionDir: envOverrides.CLAUDE_CONFIG_DIR,
|
||||
chatJid: input.chatJid,
|
||||
isMain: input.isMain,
|
||||
groupFolder: group.folder,
|
||||
agentType: group.agentType || 'claude-code',
|
||||
memoryBriefing: input.memoryBriefing,
|
||||
role: input.roomRoleContext.role,
|
||||
ipcDir: env[EJCLAW_ENV.ipcDir],
|
||||
hostIpcDir: env[EJCLAW_ENV.hostIpcDir],
|
||||
workDir: envOverrides[EJCLAW_ENV.workDir] || env[EJCLAW_ENV.workDir],
|
||||
skillOverrides,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isCodexGroup) {
|
||||
releaseCodexAuthSession(previousCodexSessionAuth);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (isCodexGroup) {
|
||||
releaseCodexAuthSession(previousCodexSessionAuth);
|
||||
codexSessionAuth = readonlySession.codexSessionAuth;
|
||||
env.CODEX_HOME = path.join(envOverrides.CLAUDE_CONFIG_DIR, '.codex');
|
||||
if (readonlySession.codexHomeDir) {
|
||||
env.HOME = readonlySession.codexHomeDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.runId) {
|
||||
env.EJCLAW_RUN_ID = input.runId;
|
||||
env[EJCLAW_ENV.runId] = input.runId;
|
||||
}
|
||||
|
||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const processSuffix = input.runId || `${Date.now()}`;
|
||||
const processName = `ejclaw-${safeName}-${processSuffix}`;
|
||||
const finalizeCodexAuthSessionOnce = createCodexAuthSessionFinalizer(
|
||||
() => codexSessionAuth,
|
||||
);
|
||||
|
||||
// Check if runner is built
|
||||
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
||||
@@ -120,6 +207,7 @@ export async function runAgentProcess(
|
||||
{ runnerDir, chatJid: input.chatJid, runId: input.runId },
|
||||
'Runner not built. Run: cd runners/agent-runner && bun install && bun run build',
|
||||
);
|
||||
releaseCodexAuthSession(codexSessionAuth);
|
||||
return {
|
||||
status: 'error',
|
||||
result: null,
|
||||
@@ -150,19 +238,40 @@ export async function runAgentProcess(
|
||||
env,
|
||||
});
|
||||
|
||||
onProcess(proc, processName, env.EJCLAW_IPC_DIR);
|
||||
onProcess(proc, processName, env[EJCLAW_ENV.ipcDir]);
|
||||
|
||||
proc.stdin.write(JSON.stringify(input));
|
||||
const runnerInput: AgentInput = {
|
||||
...input,
|
||||
...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}),
|
||||
};
|
||||
proc.stdin.write(JSON.stringify(runnerInput));
|
||||
proc.stdin.end();
|
||||
|
||||
runSpawnedAgentProcess({
|
||||
proc,
|
||||
group,
|
||||
input,
|
||||
input: runnerInput,
|
||||
processName,
|
||||
logsDir,
|
||||
startTime,
|
||||
onOutput,
|
||||
}).then(resolve);
|
||||
onTerminalStreamedOutputFlushed: finalizeCodexAuthSessionOnce,
|
||||
})
|
||||
.then((output) => {
|
||||
finalizeCodexAuthSessionOnce();
|
||||
resolve(output);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
finalizeCodexAuthSessionOnce();
|
||||
logger.error(
|
||||
{ err, processName, chatJid: input.chatJid, runId: input.runId },
|
||||
'Spawned agent process runner failed',
|
||||
);
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
21
src/attachment-base-dirs.ts
Normal file
21
src/attachment-base-dirs.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
function unique(values: Array<string | null | undefined>): string[] {
|
||||
return [
|
||||
...new Set(values.filter((value): value is string => Boolean(value))),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveRuntimeAttachmentBaseDirs(
|
||||
group: RegisteredGroup,
|
||||
): string[] | undefined {
|
||||
const workspaceRoot = isValidGroupFolder(group.folder)
|
||||
? path.resolve(DATA_DIR, 'workspaces', group.folder)
|
||||
: null;
|
||||
const dirs = unique([group.workDir, workspaceRoot]);
|
||||
return dirs.length > 0 ? dirs : undefined;
|
||||
}
|
||||
155
src/channels/discord-attachments.ts
Normal file
155
src/channels/discord-attachments.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Attachment } from 'discord.js';
|
||||
import { isModelDocumentPath } from 'ejclaw-runners-shared';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
||||
const TEXT_ATTACHMENT_NAME_PATTERN =
|
||||
/\.(txt|md|json|csv|log|xml|yaml|yml|toml|ini|cfg|conf|sh|bash|zsh|py|js|ts|jsx|tsx|html|css|sql|rs|go|java|c|cpp|h|hpp|rb|php|swift|kt|scala|r|lua|pl|ex|exs|hs|ml|clj|dart|v|zig|nim|ps1|bat|cmd|mjs|cjs)$/i;
|
||||
|
||||
async function downloadAttachment(
|
||||
att: Attachment,
|
||||
defaultExt = '.bin',
|
||||
): Promise<string> {
|
||||
const res = await fetch(att.url);
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
const ext = path.extname(att.name || `file${defaultExt}`) || defaultExt;
|
||||
const attachmentId = String(att.id || 'attachment').replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
'_',
|
||||
);
|
||||
const filename = `${Date.now()}-${attachmentId}${ext}`;
|
||||
const filePath = path.join(ATTACHMENTS_DIR, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
logger.info({ file: filename, size: buffer.length }, 'Attachment downloaded');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function attachmentDefaultExtension(contentType: string): string {
|
||||
if (contentType.startsWith('image/')) return '.png';
|
||||
if (contentType.startsWith('audio/')) return '.wav';
|
||||
if (contentType.startsWith('video/')) return '.mp4';
|
||||
if (contentType.startsWith('text/')) return '.txt';
|
||||
if (contentType === 'application/pdf') return '.pdf';
|
||||
if (
|
||||
contentType === 'application/zip' ||
|
||||
contentType === 'application/x-zip-compressed'
|
||||
) {
|
||||
return '.zip';
|
||||
}
|
||||
return '.bin';
|
||||
}
|
||||
|
||||
function attachmentLabel(
|
||||
contentType: string,
|
||||
): 'Audio' | 'File' | 'Image' | 'Video' {
|
||||
if (contentType.startsWith('image/')) return 'Image';
|
||||
if (contentType.startsWith('audio/')) return 'Audio';
|
||||
if (contentType.startsWith('video/')) return 'Video';
|
||||
return 'File';
|
||||
}
|
||||
|
||||
function isTextAttachment(att: Attachment, contentType: string): boolean {
|
||||
return (
|
||||
contentType.startsWith('text/') ||
|
||||
TEXT_ATTACHMENT_NAME_PATTERN.test(att.name || '')
|
||||
);
|
||||
}
|
||||
|
||||
function isStructuredDocumentAttachment(
|
||||
att: Attachment,
|
||||
contentType: string,
|
||||
): boolean {
|
||||
if (contentType === 'application/pdf') return true;
|
||||
if (contentType.startsWith('text/')) return true;
|
||||
return isModelDocumentPath(att.name || '');
|
||||
}
|
||||
|
||||
function formatAttachmentReference(
|
||||
label: 'Audio' | 'File' | 'Image' | 'Video',
|
||||
att: Attachment,
|
||||
filePath: string,
|
||||
): string {
|
||||
return `[${label}: ${att.name || 'file'} → ${filePath}]`;
|
||||
}
|
||||
|
||||
function formatUnavailableAttachmentReference(
|
||||
label: 'Audio' | 'File' | 'Image' | 'Video',
|
||||
att: Attachment,
|
||||
filePath: string,
|
||||
contentType: string,
|
||||
): string {
|
||||
return `[${label} unavailable: ${att.name || 'file'} → ${filePath} — ${contentType} is not loaded as structured model input]`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function getDeclaredAttachmentSize(att: Attachment): number | null {
|
||||
return typeof att.size === 'number' && Number.isFinite(att.size)
|
||||
? att.size
|
||||
: null;
|
||||
}
|
||||
|
||||
function formatTooLargeAttachment(
|
||||
label: 'Audio' | 'File' | 'Image' | 'Video',
|
||||
att: Attachment,
|
||||
size: number,
|
||||
): string {
|
||||
return `[${label}: ${att.name || 'file'} (too large to download: ${formatBytes(size)} > ${formatBytes(MAX_ATTACHMENT_BYTES)})]`;
|
||||
}
|
||||
|
||||
export async function describeDownloadedAttachment(
|
||||
att: Attachment,
|
||||
contentType: string,
|
||||
): Promise<string> {
|
||||
const label = attachmentLabel(contentType);
|
||||
const declaredSize = getDeclaredAttachmentSize(att);
|
||||
if (declaredSize != null && declaredSize > MAX_ATTACHMENT_BYTES) {
|
||||
logger.warn(
|
||||
{
|
||||
file: att.name,
|
||||
size: declaredSize,
|
||||
maxSize: MAX_ATTACHMENT_BYTES,
|
||||
},
|
||||
'Attachment skipped because it exceeds the download size cap',
|
||||
);
|
||||
return formatTooLargeAttachment(label, att, declaredSize);
|
||||
}
|
||||
|
||||
const filePath = await downloadAttachment(
|
||||
att,
|
||||
attachmentDefaultExtension(contentType),
|
||||
);
|
||||
const reference = formatAttachmentReference(label, att, filePath);
|
||||
if (label === 'File' && isStructuredDocumentAttachment(att, contentType)) {
|
||||
if (!isTextAttachment(att, contentType)) return reference;
|
||||
}
|
||||
if (!isTextAttachment(att, contentType)) {
|
||||
if (label === 'Image') return reference;
|
||||
return formatUnavailableAttachmentReference(
|
||||
label,
|
||||
att,
|
||||
filePath,
|
||||
contentType,
|
||||
);
|
||||
}
|
||||
|
||||
let text = fs.readFileSync(filePath, 'utf8');
|
||||
const MAX_TEXT_LENGTH = 32_000;
|
||||
if (text.length > MAX_TEXT_LENGTH) {
|
||||
text =
|
||||
text.slice(0, MAX_TEXT_LENGTH) +
|
||||
`\n...(truncated, ${text.length} chars total)`;
|
||||
}
|
||||
return `${reference}\n${text}`;
|
||||
}
|
||||
95
src/channels/discord-dashboard-create-cleanup.test.ts
Normal file
95
src/channels/discord-dashboard-create-cleanup.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
STATUS_CHANNEL_ID: 'status-channel',
|
||||
}));
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const readDashboardStatusMessageIdMock = vi.hoisted(() =>
|
||||
vi.fn(() => 'dashboard-current'),
|
||||
);
|
||||
|
||||
vi.mock('../status-dashboard.js', () => ({
|
||||
readDashboardStatusMessageId: readDashboardStatusMessageIdMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteOwnDashboardDuplicateOnCreate,
|
||||
isDashboardTrackedSend,
|
||||
} from './discord-dashboard-create-cleanup.js';
|
||||
|
||||
function makeDashboardMessage(overrides: {
|
||||
id?: string;
|
||||
channelId?: string;
|
||||
content?: string;
|
||||
}) {
|
||||
return {
|
||||
id: overrides.id ?? 'dashboard-stale',
|
||||
channelId: overrides.channelId ?? 'status-channel',
|
||||
content: overrides.content ?? '🤖 *모델 구성*\nMemory 3.3/91.4GB',
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('discord dashboard create cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readDashboardStatusMessageIdMock.mockReturnValue('dashboard-current');
|
||||
});
|
||||
|
||||
it('detects tracked dashboard sends', () => {
|
||||
expect(
|
||||
isDashboardTrackedSend(
|
||||
'dc:status-channel',
|
||||
'🤖 *모델 구성*\nMemory 7.5/251.7GB',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isDashboardTrackedSend('dc:other', '🤖 *모델 구성*')).toBe(false);
|
||||
expect(isDashboardTrackedSend('dc:status-channel', 'normal')).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes stale own dashboard messages on create event', async () => {
|
||||
const msg = makeDashboardMessage({ id: 'dashboard-stale' });
|
||||
|
||||
await deleteOwnDashboardDuplicateOnCreate({
|
||||
message: msg,
|
||||
channelName: 'discord',
|
||||
graceUntil: 0,
|
||||
now: 100,
|
||||
});
|
||||
|
||||
expect(msg.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the tracked dashboard message', async () => {
|
||||
const msg = makeDashboardMessage({ id: 'dashboard-current' });
|
||||
|
||||
await deleteOwnDashboardDuplicateOnCreate({
|
||||
message: msg,
|
||||
channelName: 'discord',
|
||||
graceUntil: 0,
|
||||
now: 100,
|
||||
});
|
||||
|
||||
expect(msg.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps dashboard messages during tracked send grace period', async () => {
|
||||
const msg = makeDashboardMessage({ id: 'dashboard-new' });
|
||||
|
||||
await deleteOwnDashboardDuplicateOnCreate({
|
||||
message: msg,
|
||||
channelName: 'discord',
|
||||
graceUntil: 200,
|
||||
now: 100,
|
||||
});
|
||||
|
||||
expect(msg.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
55
src/channels/discord-dashboard-create-cleanup.ts
Normal file
55
src/channels/discord-dashboard-create-cleanup.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Message } from 'discord.js';
|
||||
|
||||
import { STATUS_CHANNEL_ID } from '../config.js';
|
||||
import { DASHBOARD_STATUS_MESSAGE_MARKER } from '../dashboard-message-cleanup.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { readDashboardStatusMessageId } from '../status-dashboard.js';
|
||||
|
||||
export const DASHBOARD_TRACKED_SEND_GRACE_MS = 5000;
|
||||
|
||||
export function isDashboardTrackedSend(jid: string, text: string): boolean {
|
||||
return (
|
||||
Boolean(STATUS_CHANNEL_ID) &&
|
||||
jid.replace(/^dc:/, '') === STATUS_CHANNEL_ID &&
|
||||
text.includes(DASHBOARD_STATUS_MESSAGE_MARKER)
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteOwnDashboardDuplicateOnCreate(args: {
|
||||
message: Message;
|
||||
channelName: string;
|
||||
graceUntil: number;
|
||||
now?: number;
|
||||
}): Promise<void> {
|
||||
const { message } = args;
|
||||
if (!STATUS_CHANNEL_ID || message.channelId !== STATUS_CHANNEL_ID) return;
|
||||
if (!message.content.includes(DASHBOARD_STATUS_MESSAGE_MARKER)) return;
|
||||
|
||||
const keepMessageId = readDashboardStatusMessageId(STATUS_CHANNEL_ID);
|
||||
if (!keepMessageId || message.id === keepMessageId) return;
|
||||
if ((args.now ?? Date.now()) < args.graceUntil) return;
|
||||
|
||||
try {
|
||||
await message.delete();
|
||||
logger.info(
|
||||
{
|
||||
jid: `dc:${message.channelId}`,
|
||||
messageId: message.id,
|
||||
keepMessageId,
|
||||
channelName: args.channelName,
|
||||
},
|
||||
'Deleted duplicate dashboard message on Discord create event',
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{
|
||||
jid: `dc:${message.channelId}`,
|
||||
messageId: message.id,
|
||||
keepMessageId,
|
||||
channelName: args.channelName,
|
||||
err,
|
||||
},
|
||||
'Failed to delete duplicate dashboard message on Discord create event',
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/channels/discord-message-cleanup.test.ts
Normal file
90
src/channels/discord-message-cleanup.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
|
||||
|
||||
class MockMessageCollection extends Map<string, any> {
|
||||
filter(predicate: (message: any, id: string) => boolean) {
|
||||
const filtered = new MockMessageCollection();
|
||||
for (const [id, message] of this.entries()) {
|
||||
if (predicate(message, id)) filtered.set(id, message);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
describe('deleteRecentDiscordMessagesByContent', () => {
|
||||
it('deletes duplicate own dashboard messages while keeping the tracked one', async () => {
|
||||
const keepDelete = vi.fn();
|
||||
const duplicateDelete = vi.fn();
|
||||
const otherBotDelete = vi.fn();
|
||||
const normalDelete = vi.fn();
|
||||
const messages = new MockMessageCollection([
|
||||
[
|
||||
'tracked',
|
||||
{
|
||||
id: 'tracked',
|
||||
author: { id: '999888777' },
|
||||
content: '🤖 *모델 구성*\ntracked',
|
||||
createdTimestamp: Date.now(),
|
||||
delete: keepDelete,
|
||||
},
|
||||
],
|
||||
[
|
||||
'duplicate',
|
||||
{
|
||||
id: 'duplicate',
|
||||
author: { id: '999888777' },
|
||||
content: '🤖 *모델 구성*\nduplicate',
|
||||
createdTimestamp: Date.now(),
|
||||
delete: duplicateDelete,
|
||||
},
|
||||
],
|
||||
[
|
||||
'other-bot',
|
||||
{
|
||||
id: 'other-bot',
|
||||
author: { id: 'other-bot' },
|
||||
content: '🤖 *모델 구성*\nother bot',
|
||||
createdTimestamp: Date.now(),
|
||||
delete: otherBotDelete,
|
||||
},
|
||||
],
|
||||
[
|
||||
'normal',
|
||||
{
|
||||
id: 'normal',
|
||||
author: { id: '999888777' },
|
||||
content: 'normal tracked message',
|
||||
createdTimestamp: Date.now(),
|
||||
delete: normalDelete,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const client = {
|
||||
user: { id: '999888777' },
|
||||
channels: {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
messages: {
|
||||
fetch: vi.fn().mockResolvedValue(messages),
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const deleted = await deleteRecentDiscordMessagesByContent({
|
||||
client: client as any,
|
||||
channelName: 'discord',
|
||||
jid: 'dc:1234567890123456',
|
||||
options: {
|
||||
contentIncludes: '🤖 *모델 구성*',
|
||||
exceptMessageId: 'tracked',
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect(duplicateDelete).toHaveBeenCalledTimes(1);
|
||||
expect(keepDelete).not.toHaveBeenCalled();
|
||||
expect(otherBotDelete).not.toHaveBeenCalled();
|
||||
expect(normalDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
81
src/channels/discord-message-cleanup.ts
Normal file
81
src/channels/discord-message-cleanup.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Client, TextChannel } from 'discord.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import type { DeleteRecentMessagesByContentOptions } from '../types.js';
|
||||
|
||||
export async function deleteRecentDiscordMessagesByContent(args: {
|
||||
client: Client | null;
|
||||
channelName: string;
|
||||
jid: string;
|
||||
options: DeleteRecentMessagesByContentOptions;
|
||||
}): Promise<number> {
|
||||
if (!args.client) return 0;
|
||||
|
||||
const contentIncludes = args.options.contentIncludes.trim();
|
||||
if (!contentIncludes) return 0;
|
||||
|
||||
let deleted = 0;
|
||||
try {
|
||||
const channelId = args.jid.replace(/^dc:/, '');
|
||||
const channel = await args.client.channels.fetch(channelId);
|
||||
if (!channel || !('messages' in channel)) return 0;
|
||||
|
||||
const tc = channel as TextChannel;
|
||||
const messages = await tc.messages.fetch({
|
||||
limit: Math.max(1, Math.min(args.options.limit ?? 100, 100)),
|
||||
});
|
||||
const candidates = messages.filter(
|
||||
(message) =>
|
||||
message.author.id === args.client?.user?.id &&
|
||||
message.id !== args.options.exceptMessageId &&
|
||||
message.content.includes(contentIncludes),
|
||||
);
|
||||
if (candidates.size === 0) return 0;
|
||||
|
||||
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
||||
const recent = candidates.filter(
|
||||
(message) => message.createdTimestamp > twoWeeksAgo,
|
||||
);
|
||||
const old = candidates.filter(
|
||||
(message) => message.createdTimestamp <= twoWeeksAgo,
|
||||
);
|
||||
|
||||
if (recent.size >= 2 && 'bulkDelete' in channel) {
|
||||
await tc.bulkDelete(recent);
|
||||
deleted += recent.size;
|
||||
} else {
|
||||
for (const [, message] of recent) {
|
||||
await message.delete();
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, message] of old) {
|
||||
await message.delete();
|
||||
deleted += 1;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
jid: args.jid,
|
||||
deleted,
|
||||
exceptMessageId: args.options.exceptMessageId ?? null,
|
||||
channelName: args.channelName,
|
||||
},
|
||||
'Deleted duplicate Discord messages by content marker',
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
jid: args.jid,
|
||||
err,
|
||||
deleted,
|
||||
exceptMessageId: args.options.exceptMessageId ?? null,
|
||||
channelName: args.channelName,
|
||||
},
|
||||
'Failed to delete duplicate Discord messages by content marker',
|
||||
);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
73
src/channels/discord-outbound.ts
Normal file
73
src/channels/discord-outbound.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import path from 'path';
|
||||
|
||||
import { normalizeAgentOutput } from '../agent-protocol.js';
|
||||
import type { OutboundAttachment } from '../types.js';
|
||||
|
||||
const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
|
||||
|
||||
export interface PreparedDiscordOutbound {
|
||||
text: string;
|
||||
cleanText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
attachmentSource:
|
||||
| 'structured'
|
||||
| 'media-tag'
|
||||
| 'md-link'
|
||||
| 'image-tag'
|
||||
| 'mixed'
|
||||
| 'none';
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
function sanitizeLocalMarkdownLinks(text: string): string {
|
||||
return text.replace(LOCAL_MARKDOWN_LINK_RE, (_full, rawPath: string) => {
|
||||
const trimmed = rawPath.trim();
|
||||
const basename = path.basename(trimmed.replace(/#.*$/, ''));
|
||||
const lineMatch = trimmed.match(/#L(\d+)/);
|
||||
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
|
||||
});
|
||||
}
|
||||
|
||||
export function prepareDiscordOutbound(
|
||||
text: string,
|
||||
optionAttachments: OutboundAttachment[] | undefined,
|
||||
): PreparedDiscordOutbound {
|
||||
const normalized = normalizeAgentOutput(text);
|
||||
if (normalized.output?.visibility === 'silent') {
|
||||
return {
|
||||
text: '',
|
||||
cleanText: '',
|
||||
attachments: [],
|
||||
attachmentSource: 'none',
|
||||
silent: true,
|
||||
};
|
||||
}
|
||||
|
||||
const structuredOutput =
|
||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
|
||||
const cleanText = sanitizeLocalMarkdownLinks(outboundText);
|
||||
const attachments =
|
||||
optionAttachments && optionAttachments.length > 0
|
||||
? optionAttachments
|
||||
: (structuredOutput?.attachments ?? []);
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const attachmentSource =
|
||||
optionAttachments && optionAttachments.length > 0
|
||||
? 'structured'
|
||||
: normalized.attachmentSource === 'legacy-ejclaw-json'
|
||||
? 'structured'
|
||||
: normalized.attachmentSource === 'media-tag'
|
||||
? 'media-tag'
|
||||
: normalized.attachmentSource === 'markdown-image'
|
||||
? 'md-link'
|
||||
: (normalized.attachmentSource ?? 'none');
|
||||
|
||||
return {
|
||||
text: outboundText,
|
||||
cleanText,
|
||||
attachments,
|
||||
attachmentSource: hasAttachments ? attachmentSource : 'none',
|
||||
silent: false,
|
||||
};
|
||||
}
|
||||
318
src/channels/discord-structured-output.test.ts
Normal file
318
src/channels/discord-structured-output.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./registry.js', () => ({
|
||||
registerChannel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../env.js', () => ({
|
||||
readEnvFile: vi.fn(() => ({})),
|
||||
getEnv: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
ASSISTANT_NAME: 'Andy',
|
||||
TRIGGER_PATTERN: /^@Andy\b/i,
|
||||
DATA_DIR: '/tmp/ejclaw-test-data',
|
||||
CACHE_DIR: '/tmp/ejclaw-test-cache',
|
||||
}));
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../service-routing.js', () => ({
|
||||
hasReviewerLease: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
||||
|
||||
vi.mock('discord.js', () => {
|
||||
class MockClient {
|
||||
eventHandlers = new Map<string, Handler[]>();
|
||||
user: any = { id: '999888777', tag: 'Andy#1234' };
|
||||
private _ready = false;
|
||||
|
||||
constructor(_opts: any) {
|
||||
clientRef.current = this;
|
||||
}
|
||||
|
||||
on(event: string, handler: Handler) {
|
||||
this.eventHandlers.set(event, [
|
||||
...(this.eventHandlers.get(event) ?? []),
|
||||
handler,
|
||||
]);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, handler: Handler) {
|
||||
return this.on(event, handler);
|
||||
}
|
||||
|
||||
async login(_token: string) {
|
||||
this._ready = true;
|
||||
for (const handler of this.eventHandlers.get('ready') ?? []) {
|
||||
handler({ user: this.user });
|
||||
}
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
channels = {
|
||||
fetch: vi.fn().mockResolvedValue({
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
sendTyping: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Client: MockClient,
|
||||
Events: {
|
||||
MessageCreate: 'messageCreate',
|
||||
ClientReady: 'ready',
|
||||
Error: 'error',
|
||||
},
|
||||
GatewayIntentBits: {
|
||||
Guilds: 1,
|
||||
GuildMessages: 2,
|
||||
MessageContent: 4,
|
||||
DirectMessages: 8,
|
||||
},
|
||||
MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 },
|
||||
TextChannel: class TextChannel {},
|
||||
};
|
||||
});
|
||||
|
||||
import { DiscordChannel, type DiscordChannelOpts } from './discord.js';
|
||||
|
||||
const ONE_PIXEL_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MINIMAL_MP4 = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
|
||||
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
|
||||
]);
|
||||
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const file of tempFiles.splice(0)) {
|
||||
fs.rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createTestOpts(): DiscordChannelOpts {
|
||||
return {
|
||||
onMessage: vi.fn(),
|
||||
onChatMetadata: vi.fn(),
|
||||
roomBindings: vi.fn(() => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DiscordChannel structured output', () => {
|
||||
it('normalizes raw EJClaw JSON and sends direct temp images as files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
const result = await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: filePath,
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
primaryMessageId: 'discord-message-1',
|
||||
messageIds: ['discord-message-1'],
|
||||
visible: true,
|
||||
});
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
|
||||
'"ejclaw"',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes status-prefixed EJClaw JSON and keeps the status line visible', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`ejclaw-discord-image-status-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-2' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
`TASK_DONE
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '첨부 테스트입니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: filePath,
|
||||
name: 'status.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
\`\`\``,
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: 'TASK_DONE\n\n첨부 테스트입니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: 'status.png',
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
|
||||
'"ejclaw"',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes markdown images and sends them as files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`ejclaw-discord-markdown-image-${Date.now()}.png`,
|
||||
);
|
||||
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-3' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
`스크린샷입니다.\n`,
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '스크린샷입니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: path.basename(filePath),
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes MEDIA directives and sends them as files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`adventurer-active-sfx-preview-${Date.now()}.mp4`,
|
||||
);
|
||||
fs.writeFileSync(filePath, MINIMAL_MP4);
|
||||
tempFiles.push(filePath);
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-media' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
`사운드 프리뷰입니다.\nMEDIA:${filePath}`,
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '사운드 프리뷰입니다.',
|
||||
files: [
|
||||
{
|
||||
attachment: fs.realpathSync(filePath),
|
||||
name: path.basename(filePath),
|
||||
},
|
||||
],
|
||||
flags: 1 << 2,
|
||||
});
|
||||
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain('MEDIA:');
|
||||
});
|
||||
|
||||
it('keeps non-image local markdown links readable without uploading files', async () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
await channel.connect();
|
||||
const mockChannel = {
|
||||
send: vi.fn().mockResolvedValue({ id: 'discord-message-4' }),
|
||||
sendTyping: vi.fn(),
|
||||
};
|
||||
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
'수정 위치: [source](/tmp/project/src/index.ts#L42)',
|
||||
);
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '수정 위치: `index.ts:42`',
|
||||
files: undefined,
|
||||
flags: 1 << 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,25 +11,26 @@ import {
|
||||
TextChannel,
|
||||
} from 'discord.js';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
CACHE_DIR,
|
||||
DATA_DIR,
|
||||
TRIGGER_PATTERN,
|
||||
} from '../config.js';
|
||||
import { CACHE_DIR } from '../config.js';
|
||||
import { getEnv } from '../env.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { extractImageTagPaths } from '../agent-protocol.js';
|
||||
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
||||
import { formatOutbound } from '../router.js';
|
||||
import { hasReviewerLease } from '../service-routing.js';
|
||||
import type {
|
||||
EditMessageOptions,
|
||||
OutboundAttachment,
|
||||
DeleteRecentMessagesByContentOptions,
|
||||
SendMessageOptions,
|
||||
SendMessageResult,
|
||||
} from '../types.js';
|
||||
import {
|
||||
DASHBOARD_TRACKED_SEND_GRACE_MS,
|
||||
deleteOwnDashboardDuplicateOnCreate,
|
||||
isDashboardTrackedSend,
|
||||
} from './discord-dashboard-create-cleanup.js';
|
||||
import { describeDownloadedAttachment } from './discord-attachments.js';
|
||||
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
|
||||
import { prepareDiscordOutbound } from './discord-outbound.js';
|
||||
|
||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
||||
const DISCORD_OWNER_CHANNEL = 'discord';
|
||||
const DISCORD_REVIEWER_CHANNEL = 'discord-review';
|
||||
@@ -37,178 +38,6 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter';
|
||||
const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN';
|
||||
const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
|
||||
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN';
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
|
||||
|
||||
// Matches a line that opens or closes a markdown fenced code block.
|
||||
// Captures the fence marker (``` or ```` etc.) and the optional language tag.
|
||||
const FENCE_LINE_RE = /^(`{3,})([^\s`]*)\s*$/;
|
||||
|
||||
// Discord rejects any message/edit body longer than 2000 characters with
|
||||
// "Invalid Form Body … Must be 2000 or fewer in length".
|
||||
export const DISCORD_MAX_MESSAGE_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Split `text` into chunks ≤ `maxLength` chars while keeping every chunk a
|
||||
* valid standalone Markdown snippet for Discord. If a chunk has to end inside
|
||||
* a ``` fenced code block, we append a closing fence to it and reopen the
|
||||
* same fence (with the same language tag) at the start of the next chunk.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Prefers splitting at newline boundaries.
|
||||
* - If a single line is itself longer than `maxLength`, falls back to a
|
||||
* surrogate-pair-safe byte split for that line only.
|
||||
* - Preserves the surrogate-pair safety guarantee of the previous splitter.
|
||||
* - Returns `[text]` unchanged when `text.length <= maxLength`.
|
||||
*/
|
||||
export function chunkForDiscord(text: string, maxLength: number): string[] {
|
||||
if (text.length <= maxLength) return text.length > 0 ? [text] : [];
|
||||
|
||||
const lines = text.split('\n');
|
||||
const chunks: string[] = [];
|
||||
let current = '';
|
||||
let openLang = ''; // language tag of currently open fence
|
||||
let openMarker: string | null = null; // ``` or ```` etc; null when not in a fence
|
||||
|
||||
const flush = () => {
|
||||
if (current === '') return;
|
||||
if (openMarker !== null) {
|
||||
if (!current.endsWith('\n')) current += '\n';
|
||||
current += openMarker;
|
||||
chunks.push(current);
|
||||
current = openMarker + openLang + '\n';
|
||||
} else {
|
||||
chunks.push(current);
|
||||
current = '';
|
||||
}
|
||||
};
|
||||
|
||||
const hardSplitInto = (segment: string) => {
|
||||
// Split `segment` across chunks at code-unit boundaries while avoiding
|
||||
// splitting surrogate pairs. Used when a single source line exceeds the
|
||||
// budget on its own.
|
||||
let s = segment;
|
||||
while (s.length > 0) {
|
||||
const reserve = openMarker !== null ? openMarker.length + 1 : 0;
|
||||
const room = maxLength - current.length - reserve;
|
||||
if (room <= 0) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
let take = Math.min(s.length, room);
|
||||
if (
|
||||
take < s.length &&
|
||||
take > 0 &&
|
||||
s.charCodeAt(take - 1) >= 0xd800 &&
|
||||
s.charCodeAt(take - 1) <= 0xdbff
|
||||
) {
|
||||
take--;
|
||||
}
|
||||
if (take <= 0) {
|
||||
// Couldn't fit anything safely; flush and retry on an empty chunk.
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
current += s.slice(0, take);
|
||||
s = s.slice(take);
|
||||
if (s.length > 0) flush();
|
||||
}
|
||||
};
|
||||
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const isLast = li === lines.length - 1;
|
||||
const line = lines[li];
|
||||
const lineWithNL = isLast ? line : line + '\n';
|
||||
const reserve = openMarker !== null ? openMarker.length + 1 : 0;
|
||||
|
||||
if (
|
||||
current.length > 0 &&
|
||||
current.length + lineWithNL.length + reserve > maxLength
|
||||
) {
|
||||
flush();
|
||||
}
|
||||
|
||||
if (lineWithNL.length + reserve > maxLength) {
|
||||
hardSplitInto(lineWithNL);
|
||||
} else {
|
||||
current += lineWithNL;
|
||||
}
|
||||
|
||||
// Update fence state AFTER appending the line so the very-next iteration
|
||||
// accounts for it correctly.
|
||||
const m = line.match(FENCE_LINE_RE);
|
||||
if (m) {
|
||||
if (openMarker === null) {
|
||||
openMarker = m[1];
|
||||
openLang = m[2] || '';
|
||||
} else if (m[1] === openMarker) {
|
||||
openMarker = null;
|
||||
openLang = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length > 0) chunks.push(current);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function extractMarkdownImageAttachments(text: string): {
|
||||
cleanText: string;
|
||||
attachments: OutboundAttachment[];
|
||||
} {
|
||||
const attachments: OutboundAttachment[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => {
|
||||
const trimmed = rawPath.trim();
|
||||
if (IMAGE_EXTS.test(trimmed)) {
|
||||
if (!seen.has(trimmed)) {
|
||||
attachments.push({
|
||||
path: trimmed,
|
||||
name: path.basename(trimmed),
|
||||
});
|
||||
seen.add(trimmed);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const basename = path.basename(trimmed.replace(/#.*$/, ''));
|
||||
const lineMatch = trimmed.match(/#L(\d+)/);
|
||||
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
|
||||
});
|
||||
|
||||
return { cleanText, attachments };
|
||||
}
|
||||
|
||||
function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] {
|
||||
return paths
|
||||
.filter((filePath) => IMAGE_EXTS.test(filePath))
|
||||
.map((filePath) => ({
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a Discord attachment to local disk.
|
||||
* Returns the absolute path to the saved file.
|
||||
*/
|
||||
async function downloadAttachment(
|
||||
att: Attachment,
|
||||
defaultExt = '.bin',
|
||||
): Promise<string> {
|
||||
const res = await fetch(att.url);
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
const ext = path.extname(att.name || `file${defaultExt}`) || defaultExt;
|
||||
const filename = `${Date.now()}-${att.id}${ext}`;
|
||||
const filePath = path.join(ATTACHMENTS_DIR, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
logger.info({ file: filename, size: buffer.length }, 'Attachment downloaded');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a pending transcription from the other service (poll cache file).
|
||||
@@ -339,7 +168,6 @@ export interface DiscordChannelOpts {
|
||||
|
||||
export class DiscordChannel implements Channel {
|
||||
name = 'discord';
|
||||
readonly maxMessageLength = DISCORD_MAX_MESSAGE_LENGTH;
|
||||
|
||||
private client: Client | null = null;
|
||||
private opts: DiscordChannelOpts;
|
||||
@@ -349,6 +177,7 @@ export class DiscordChannel implements Channel {
|
||||
private agentTypeFilter?: AgentType;
|
||||
private receivesInbound: boolean;
|
||||
private ownsDiscordJids: boolean;
|
||||
private dashboardTrackedSendGraceUntil = 0;
|
||||
|
||||
constructor(
|
||||
botToken: string,
|
||||
@@ -381,163 +210,7 @@ export class DiscordChannel implements Channel {
|
||||
});
|
||||
|
||||
this.client.on(Events.MessageCreate, async (message: Message) => {
|
||||
const channelId = message.channelId;
|
||||
const chatJid = `dc:${channelId}`;
|
||||
if (!this.receivesInbound) return;
|
||||
const isOwnBotMessage = message.author.id === this.client?.user?.id;
|
||||
if (isOwnBotMessage) return;
|
||||
if (message.author.bot && !hasReviewerLease(chatJid)) return;
|
||||
|
||||
let content = message.content;
|
||||
const timestamp = message.createdAt.toISOString();
|
||||
const senderName =
|
||||
message.member?.displayName ||
|
||||
message.author.displayName ||
|
||||
message.author.username;
|
||||
const sender = message.author.id;
|
||||
const msgId = message.id;
|
||||
|
||||
// Determine chat name
|
||||
let chatName: string;
|
||||
if (message.guild) {
|
||||
const textChannel = message.channel as TextChannel;
|
||||
chatName = `${message.guild.name} #${textChannel.name}`;
|
||||
} else {
|
||||
chatName = senderName;
|
||||
}
|
||||
|
||||
// Handle attachments — transcribe voice messages, download files
|
||||
const isVoiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
|
||||
if (message.attachments.size > 0) {
|
||||
const attachmentDescriptions = await Promise.all(
|
||||
[...message.attachments.values()].map(async (att) => {
|
||||
const contentType = att.contentType || '';
|
||||
// Voice messages → transcribe; regular audio files → download
|
||||
if (
|
||||
contentType.startsWith('audio/') &&
|
||||
(isVoiceMessage || att.duration != null)
|
||||
) {
|
||||
return transcribeAudio(att);
|
||||
} else if (
|
||||
contentType.startsWith('audio/') ||
|
||||
contentType.startsWith('image/')
|
||||
) {
|
||||
try {
|
||||
const filePath = await downloadAttachment(
|
||||
att,
|
||||
contentType.startsWith('image/') ? '.png' : '.wav',
|
||||
);
|
||||
const label = contentType.startsWith('image/')
|
||||
? 'Image'
|
||||
: 'Audio';
|
||||
const origName = att.name || 'file';
|
||||
return `[${label}: ${origName} → ${filePath}]`;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, file: att.name },
|
||||
'Attachment download failed',
|
||||
);
|
||||
return `[File: ${att.name || 'file'} (download failed)]`;
|
||||
}
|
||||
} else if (contentType.startsWith('video/')) {
|
||||
return `[Video: ${att.name || 'video'}]`;
|
||||
} else if (
|
||||
contentType.startsWith('text/') ||
|
||||
/\.(txt|md|json|csv|log|xml|yaml|yml|toml|ini|cfg|conf|sh|bash|zsh|py|js|ts|jsx|tsx|html|css|sql|rs|go|java|c|cpp|h|hpp|rb|php|swift|kt|scala|r|lua|pl|ex|exs|hs|ml|clj|dart|v|zig|nim|ps1|bat|cmd|mjs|cjs)$/i.test(
|
||||
att.name || '',
|
||||
)
|
||||
) {
|
||||
// Download and inline text-based files
|
||||
try {
|
||||
const res = await fetch(att.url);
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
let text = await res.text();
|
||||
// Truncate very large files
|
||||
const MAX_TEXT_LENGTH = 32_000;
|
||||
if (text.length > MAX_TEXT_LENGTH) {
|
||||
text =
|
||||
text.slice(0, MAX_TEXT_LENGTH) +
|
||||
`\n...(truncated, ${text.length} chars total)`;
|
||||
}
|
||||
return `[File: ${att.name}]\n${text}`;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, file: att.name },
|
||||
'Text file download failed',
|
||||
);
|
||||
return `[File: ${att.name || 'file'} (download failed)]`;
|
||||
}
|
||||
} else {
|
||||
return `[File: ${att.name || 'file'}]`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (content) {
|
||||
content = `${content}\n${attachmentDescriptions.join('\n')}`;
|
||||
} else {
|
||||
content = attachmentDescriptions.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reply context — include who the user is replying to
|
||||
if (message.reference?.messageId) {
|
||||
try {
|
||||
const repliedTo = await message.channel.messages.fetch(
|
||||
message.reference.messageId,
|
||||
);
|
||||
const replyAuthor =
|
||||
repliedTo.member?.displayName ||
|
||||
repliedTo.author.displayName ||
|
||||
repliedTo.author.username;
|
||||
content = `[Reply to ${replyAuthor}] ${content}`;
|
||||
} catch {
|
||||
// Referenced message may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
// Store chat metadata for discovery
|
||||
const isGroup = message.guild !== null;
|
||||
this.opts.onChatMetadata(
|
||||
chatJid,
|
||||
timestamp,
|
||||
chatName,
|
||||
'discord',
|
||||
isGroup,
|
||||
);
|
||||
|
||||
// Only deliver full message for registered groups. Secondary role bots
|
||||
// are configured as outbound-only, while the owner bot receives inbound.
|
||||
const group = this.opts.roomBindings()[chatJid];
|
||||
if (!group) {
|
||||
logger.debug(
|
||||
{ chatJid, chatName },
|
||||
'Message from unregistered Discord channel',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.agentTypeFilter &&
|
||||
(group.agentType || 'claude-code') !== this.agentTypeFilter
|
||||
) {
|
||||
return; // This JID belongs to a different agent type's bot
|
||||
}
|
||||
|
||||
// Deliver message — startMessageLoop() will pick it up
|
||||
this.opts.onMessage(chatJid, {
|
||||
id: msgId,
|
||||
chat_jid: chatJid,
|
||||
sender,
|
||||
sender_name: senderName,
|
||||
content,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
is_bot_message: message.author.bot ?? false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ chatJid, chatName, sender: senderName },
|
||||
'Discord message stored',
|
||||
);
|
||||
await this.handleMessageCreate(message);
|
||||
});
|
||||
|
||||
// Handle errors gracefully
|
||||
@@ -545,7 +218,7 @@ export class DiscordChannel implements Channel {
|
||||
logger.error({ err: err.message }, 'Discord client error');
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.client!.once(Events.ClientReady, (readyClient) => {
|
||||
logger.info(
|
||||
{ username: readyClient.user.tag, id: readyClient.user.id },
|
||||
@@ -558,18 +231,147 @@ export class DiscordChannel implements Channel {
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client!.login(this.botToken);
|
||||
void this.client!.login(this.botToken).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessageCreate(message: Message): Promise<void> {
|
||||
const channelId = message.channelId;
|
||||
const chatJid = `dc:${channelId}`;
|
||||
const isOwnBotMessage = message.author.id === this.client?.user?.id;
|
||||
if (isOwnBotMessage) {
|
||||
await deleteOwnDashboardDuplicateOnCreate({
|
||||
message,
|
||||
channelName: this.name,
|
||||
graceUntil: this.dashboardTrackedSendGraceUntil,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.receivesInbound) return;
|
||||
if (message.author.bot && !hasReviewerLease(chatJid)) return;
|
||||
|
||||
let content = message.content;
|
||||
const timestamp = message.createdAt.toISOString();
|
||||
const senderName =
|
||||
message.member?.displayName ||
|
||||
message.author.displayName ||
|
||||
message.author.username;
|
||||
const sender = message.author.id;
|
||||
const msgId = message.id;
|
||||
|
||||
// Determine chat name
|
||||
let chatName: string;
|
||||
if (message.guild) {
|
||||
const textChannel = message.channel as TextChannel;
|
||||
chatName = `${message.guild.name} #${textChannel.name}`;
|
||||
} else {
|
||||
chatName = senderName;
|
||||
}
|
||||
|
||||
// Handle attachments — transcribe voice messages, download files
|
||||
const isVoiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
|
||||
if (message.attachments.size > 0) {
|
||||
const attachmentDescriptions = await Promise.all(
|
||||
[...message.attachments.values()].map(async (att) => {
|
||||
const contentType = att.contentType || '';
|
||||
// Voice messages → transcribe; all attachments still get a file path.
|
||||
if (
|
||||
contentType.startsWith('audio/') &&
|
||||
(isVoiceMessage || att.duration != null)
|
||||
) {
|
||||
try {
|
||||
const transcript = await transcribeAudio(att);
|
||||
const reference = await describeDownloadedAttachment(
|
||||
att,
|
||||
contentType,
|
||||
);
|
||||
return `${transcript}\n${reference}`;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, file: att.name },
|
||||
'Voice attachment handling failed',
|
||||
);
|
||||
return `[File: ${att.name || 'file'} (download failed)]`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await describeDownloadedAttachment(att, contentType);
|
||||
} catch (err) {
|
||||
logger.error({ err, file: att.name }, 'Attachment download failed');
|
||||
return `[File: ${att.name || 'file'} (download failed)]`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (content) {
|
||||
content = `${content}\n${attachmentDescriptions.join('\n')}`;
|
||||
} else {
|
||||
content = attachmentDescriptions.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reply context — include who the user is replying to
|
||||
if (message.reference?.messageId) {
|
||||
try {
|
||||
const repliedTo = await message.channel.messages.fetch(
|
||||
message.reference.messageId,
|
||||
);
|
||||
const replyAuthor =
|
||||
repliedTo.member?.displayName ||
|
||||
repliedTo.author.displayName ||
|
||||
repliedTo.author.username;
|
||||
content = `[Reply to ${replyAuthor}] ${content}`;
|
||||
} catch {
|
||||
// Referenced message may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
// Store chat metadata for discovery
|
||||
const isGroup = message.guild !== null;
|
||||
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
|
||||
|
||||
// Only deliver full message for registered groups. Secondary role bots
|
||||
// are configured as outbound-only, while the owner bot receives inbound.
|
||||
const group = this.opts.roomBindings()[chatJid];
|
||||
if (!group) {
|
||||
logger.debug(
|
||||
{ chatJid, chatName },
|
||||
'Message from unregistered Discord channel',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.agentTypeFilter &&
|
||||
(group.agentType || 'claude-code') !== this.agentTypeFilter
|
||||
) {
|
||||
return; // This JID belongs to a different agent type's bot
|
||||
}
|
||||
|
||||
// Deliver message — startMessageLoop() will pick it up
|
||||
this.opts.onMessage(chatJid, {
|
||||
id: msgId,
|
||||
chat_jid: chatJid,
|
||||
sender,
|
||||
sender_name: senderName,
|
||||
content,
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
is_bot_message: message.author.bot ?? false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ chatJid, chatName, sender: senderName },
|
||||
'Discord message stored',
|
||||
);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
jid: string,
|
||||
text: string,
|
||||
options: SendMessageOptions = {},
|
||||
): Promise<void> {
|
||||
): Promise<SendMessageResult> {
|
||||
if (!this.client) {
|
||||
logger.warn('Discord client not initialized');
|
||||
return;
|
||||
throw new Error('Discord client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -577,32 +379,20 @@ export class DiscordChannel implements Channel {
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
|
||||
if (!channel || !('send' in channel)) {
|
||||
logger.warn({ jid }, 'Discord channel not found or not text-based');
|
||||
return;
|
||||
throw new Error(`Discord channel not found or not text-based: ${jid}`);
|
||||
}
|
||||
|
||||
const textChannel = channel as TextChannel;
|
||||
|
||||
const structuredAttachments = options.attachments ?? [];
|
||||
const hasStructuredAttachments = structuredAttachments.length > 0;
|
||||
const markdownExtracted = extractMarkdownImageAttachments(text);
|
||||
const imageTagExtracted = extractImageTagPaths(
|
||||
markdownExtracted.cleanText,
|
||||
);
|
||||
const legacyImageTagAttachments = imageTagPathsToAttachments(
|
||||
imageTagExtracted.imagePaths,
|
||||
);
|
||||
const outboundAttachments = hasStructuredAttachments
|
||||
? structuredAttachments
|
||||
: [...markdownExtracted.attachments, ...legacyImageTagAttachments];
|
||||
const attachmentSource = hasStructuredAttachments
|
||||
? 'structured'
|
||||
: markdownExtracted.attachments.length > 0
|
||||
? 'md-link'
|
||||
: legacyImageTagAttachments.length > 0
|
||||
? 'image-tag'
|
||||
: 'none';
|
||||
const validation = validateOutboundAttachments(outboundAttachments, {
|
||||
const outbound = prepareDiscordOutbound(text, options.attachments);
|
||||
if (outbound.silent) {
|
||||
logger.debug(
|
||||
{ jid, channelName: this.name },
|
||||
'Skipping silent structured Discord outbound message',
|
||||
);
|
||||
return { primaryMessageId: null, messageIds: [], visible: false };
|
||||
}
|
||||
const validation = validateOutboundAttachments(outbound.attachments, {
|
||||
baseDirs: options.attachmentBaseDirs,
|
||||
});
|
||||
const files = validation.files;
|
||||
@@ -612,29 +402,24 @@ export class DiscordChannel implements Channel {
|
||||
{
|
||||
jid,
|
||||
channelName: this.name,
|
||||
attachmentSource,
|
||||
attachmentSource: outbound.attachmentSource,
|
||||
rejected: validation.rejected,
|
||||
},
|
||||
'Rejected outbound Discord attachments',
|
||||
);
|
||||
}
|
||||
|
||||
let cleaned = imageTagExtracted.cleanText
|
||||
.replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines
|
||||
let cleaned = outbound.cleanText
|
||||
.replace(/^[ \t]*[•*-][ \t]*$/gm, '') // remove empty bullet lines
|
||||
.replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines
|
||||
.trim();
|
||||
|
||||
// Convert @username mentions to Discord mention format
|
||||
const mentionMap: Record<string, string> = {
|
||||
눈쟁이: '216851709744513024',
|
||||
};
|
||||
for (const [name, id] of Object.entries(mentionMap)) {
|
||||
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
|
||||
}
|
||||
cleaned = options.rawMarkdown ? cleaned : formatOutbound(cleaned);
|
||||
cleaned = cleaned.replaceAll('@눈쟁이', '<@216851709744513024>');
|
||||
cleaned = formatOutbound(cleaned);
|
||||
|
||||
// Discord has a 2000 character limit per message and 10 attachments per message
|
||||
const MAX_LENGTH = DISCORD_MAX_MESSAGE_LENGTH;
|
||||
const MAX_LENGTH = 2000;
|
||||
const MAX_ATTACHMENTS = 10;
|
||||
const sentMessageIds: string[] = [];
|
||||
let chunkCount = 0;
|
||||
@@ -649,7 +434,7 @@ export class DiscordChannel implements Channel {
|
||||
{ jid, channelName: this.name },
|
||||
'Skipping empty Discord outbound message',
|
||||
);
|
||||
return;
|
||||
return { primaryMessageId: null, messageIds: [], visible: false };
|
||||
}
|
||||
|
||||
// Split files into batches of MAX_ATTACHMENTS
|
||||
@@ -677,13 +462,10 @@ export class DiscordChannel implements Channel {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Send text in chunks. The chunker preserves fenced code blocks so a
|
||||
// long ``` block that straddles the 2000-char boundary doesn't get
|
||||
// split mid-fence (which Discord would render as literal backticks
|
||||
// and break syntax for following chunks).
|
||||
const messageChunks = chunkForDiscord(cleaned, MAX_LENGTH);
|
||||
// Send text in chunks, attach first batch to the first chunk
|
||||
let fileBatchIndex = 0;
|
||||
for (const chunk of messageChunks) {
|
||||
for (let i = 0; i < cleaned.length; i += MAX_LENGTH) {
|
||||
const chunk = cleaned.slice(i, i + MAX_LENGTH);
|
||||
const batch = fileBatches[fileBatchIndex];
|
||||
recordSentMessage(
|
||||
await textChannel.send({
|
||||
@@ -708,11 +490,11 @@ export class DiscordChannel implements Channel {
|
||||
{
|
||||
jid,
|
||||
channelName: this.name,
|
||||
length: text.length,
|
||||
length: outbound.text.length,
|
||||
deliveryMode: 'send',
|
||||
chunkCount,
|
||||
attachmentCount: files.length,
|
||||
attachmentSource,
|
||||
attachmentSource: outbound.attachmentSource,
|
||||
messageId: sentMessageIds[0] ?? null,
|
||||
messageIds: sentMessageIds,
|
||||
botUserId: this.client.user?.id ?? null,
|
||||
@@ -720,6 +502,11 @@ export class DiscordChannel implements Channel {
|
||||
},
|
||||
'Discord message sent',
|
||||
);
|
||||
return {
|
||||
primaryMessageId: sentMessageIds[0] ?? null,
|
||||
messageIds: sentMessageIds,
|
||||
visible: chunkCount > 0,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ jid, channelName: this.name, err },
|
||||
@@ -798,18 +585,21 @@ export class DiscordChannel implements Channel {
|
||||
);
|
||||
}
|
||||
|
||||
async sendAndTrack(
|
||||
jid: string,
|
||||
text: string,
|
||||
options: EditMessageOptions = {},
|
||||
): Promise<string | null> {
|
||||
if (!this.client) return null;
|
||||
async sendAndTrack(jid: string, text: string): Promise<string | null> {
|
||||
if (!this.client) {
|
||||
throw new Error('Discord client not initialized');
|
||||
}
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
if (isDashboardTrackedSend(jid, text)) {
|
||||
this.dashboardTrackedSendGraceUntil =
|
||||
Date.now() + DASHBOARD_TRACKED_SEND_GRACE_MS;
|
||||
}
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (!channel || !('send' in channel)) return null;
|
||||
const cleaned = options.rawMarkdown ? text : formatOutbound(text);
|
||||
const msg = await (channel as TextChannel).send(cleaned);
|
||||
if (!channel || !('send' in channel)) {
|
||||
throw new Error(`Discord channel not found or not text-based: ${jid}`);
|
||||
}
|
||||
const msg = await (channel as TextChannel).send(text);
|
||||
logger.info(
|
||||
{
|
||||
jid,
|
||||
@@ -818,7 +608,7 @@ export class DiscordChannel implements Channel {
|
||||
messageId: msg.id,
|
||||
botUserId: this.client.user?.id ?? null,
|
||||
botUsername: this.client.user?.username ?? null,
|
||||
length: cleaned.length,
|
||||
length: text.length,
|
||||
},
|
||||
'Discord tracked message sent',
|
||||
);
|
||||
@@ -935,37 +725,41 @@ export class DiscordChannel implements Channel {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async deleteRecentMessagesByContent(
|
||||
jid: string,
|
||||
options: DeleteRecentMessagesByContentOptions,
|
||||
): Promise<number> {
|
||||
return deleteRecentDiscordMessagesByContent({
|
||||
client: this.client,
|
||||
channelName: this.name,
|
||||
jid,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
async editMessage(
|
||||
jid: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
options: EditMessageOptions = {},
|
||||
): Promise<void> {
|
||||
if (!this.client) return;
|
||||
if (!this.client) {
|
||||
throw new Error('Discord client not initialized');
|
||||
}
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (!channel || !('messages' in channel)) return;
|
||||
if (!channel || !('messages' in channel)) {
|
||||
throw new Error(`Discord channel not found or not editable: ${jid}`);
|
||||
}
|
||||
const msg = await (channel as TextChannel).messages.fetch(messageId);
|
||||
// Run the same sanitization pipeline as sendMessage so streaming
|
||||
// edits do not bypass markdown neutralization (backticks, headings,
|
||||
// italics, etc. that Discord would otherwise render and mangle).
|
||||
// Dashboard callers opt out with rawMarkdown: true to preserve
|
||||
// intentional bold/italic section headers.
|
||||
const cleaned = options.rawMarkdown ? text : formatOutbound(text);
|
||||
// editMessage only ever edits a single message. Callers that may produce
|
||||
// text longer than DISCORD_MAX_MESSAGE_LENGTH (e.g. final result
|
||||
// delivery) must route to sendMessage instead; this method must stay a
|
||||
// pure single-message edit so repeated edits (progress ticker, dashboard
|
||||
// refresh) never spawn extra follow-up messages.
|
||||
await msg.edit(cleaned);
|
||||
await msg.edit(text);
|
||||
logger.info(
|
||||
{
|
||||
jid,
|
||||
channelName: this.name,
|
||||
deliveryMode: 'edit',
|
||||
messageId,
|
||||
length: cleaned.length,
|
||||
length: text.length,
|
||||
botUserId: this.client.user?.id ?? null,
|
||||
botUsername: this.client.user?.username ?? null,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
registerChannel,
|
||||
|
||||
39
src/claude-session-settings.test.ts
Normal file
39
src/claude-session-settings.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
|
||||
|
||||
describe('claude-session-settings', () => {
|
||||
let tempDir: string;
|
||||
let hostSettingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-claude-settings-'));
|
||||
hostSettingsPath = path.join(tempDir, 'host-settings.json');
|
||||
process.env.EJCLAW_CLAUDE_SETTINGS_PATH = hostSettingsPath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EJCLAW_CLAUDE_SETTINGS_PATH;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('syncs host fastMode into the Claude session settings file', () => {
|
||||
fs.writeFileSync(
|
||||
hostSettingsPath,
|
||||
`${JSON.stringify({ fastMode: true }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const sessionDir = path.join(tempDir, 'session');
|
||||
ensureClaudeSessionSettings(sessionDir);
|
||||
|
||||
const session = JSON.parse(
|
||||
fs.readFileSync(path.join(sessionDir, 'settings.json'), 'utf-8'),
|
||||
) as { fastMode?: boolean; env?: Record<string, string> };
|
||||
expect(session.fastMode).toBe(true);
|
||||
expect(session.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1');
|
||||
});
|
||||
});
|
||||
68
src/claude-session-settings.ts
Normal file
68
src/claude-session-settings.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_CLAUDE_SESSION_ENV = {
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
} as const;
|
||||
|
||||
function settingsHomeDir(): string {
|
||||
return process.env.EJCLAW_SETTINGS_HOME || os.homedir();
|
||||
}
|
||||
|
||||
export function claudeHostSettingsPath(): string {
|
||||
const override = process.env.EJCLAW_CLAUDE_SETTINGS_PATH?.trim();
|
||||
if (override) return override;
|
||||
return path.join(settingsHomeDir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
export function readHostClaudeFastMode(): boolean {
|
||||
const file = claudeHostSettingsPath();
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return data.fastMode === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureClaudeSessionSettings(groupSessionsDir: string): void {
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
let data: Record<string, unknown> = {
|
||||
env: { ...DEFAULT_CLAUDE_SESSION_ENV },
|
||||
};
|
||||
|
||||
if (fs.existsSync(settingsFile)) {
|
||||
try {
|
||||
const existing = JSON.parse(
|
||||
fs.readFileSync(settingsFile, 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
data = {
|
||||
...existing,
|
||||
env: {
|
||||
...DEFAULT_CLAUDE_SESSION_ENV,
|
||||
...(typeof existing.env === 'object' && existing.env !== null
|
||||
? (existing.env as Record<string, unknown>)
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
data = { env: { ...DEFAULT_CLAUDE_SESSION_ENV } };
|
||||
}
|
||||
}
|
||||
|
||||
if (readHostClaudeFastMode()) {
|
||||
data.fastMode = true;
|
||||
} else {
|
||||
delete data.fastMode;
|
||||
}
|
||||
|
||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||
fs.writeFileSync(settingsFile, `${JSON.stringify(data, null, 2)}\n`);
|
||||
}
|
||||
7
src/codex-bad-request-signal.ts
Normal file
7
src/codex-bad-request-signal.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const CODEX_BAD_REQUEST_DETAIL_JSON = '{"detail":"Bad Request"}';
|
||||
|
||||
export function isCodexBadRequestText(
|
||||
text: string | null | undefined,
|
||||
): boolean {
|
||||
return text?.trim() === CODEX_BAD_REQUEST_DETAIL_JSON;
|
||||
}
|
||||
30
src/codex-config-features.test.ts
Normal file
30
src/codex-config-features.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
readCodexFeatureFromContent,
|
||||
writeCodexFeatureInContent,
|
||||
} from './codex-config-features.js';
|
||||
|
||||
describe('codex-config-features', () => {
|
||||
it('reads feature flags from the [features] section', () => {
|
||||
const toml = `
|
||||
model = "gpt-5.5"
|
||||
|
||||
[features]
|
||||
fast_mode = true
|
||||
goals = false
|
||||
`;
|
||||
expect(readCodexFeatureFromContent(toml, 'fast_mode')).toBe(true);
|
||||
expect(readCodexFeatureFromContent(toml, 'goals')).toBe(false);
|
||||
});
|
||||
|
||||
it('writes missing feature flags into [features]', () => {
|
||||
const updated = writeCodexFeatureInContent(
|
||||
'model = "gpt-5.5"\n',
|
||||
'goals',
|
||||
true,
|
||||
);
|
||||
expect(updated).toContain('[features]');
|
||||
expect(updated).toContain('goals = true');
|
||||
});
|
||||
});
|
||||
83
src/codex-config-features.ts
Normal file
83
src/codex-config-features.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export type CodexConfigFeature = 'fast_mode' | 'goals';
|
||||
|
||||
export function codexConfigPath(): string {
|
||||
const override = process.env.EJCLAW_CODEX_CONFIG_PATH?.trim();
|
||||
if (override) return override;
|
||||
const home = process.env.EJCLAW_SETTINGS_HOME || os.homedir();
|
||||
return path.join(home, '.codex', 'config.toml');
|
||||
}
|
||||
|
||||
export function readCodexFeatureFromContent(
|
||||
content: string,
|
||||
feature: CodexConfigFeature,
|
||||
): boolean {
|
||||
const lines = content.split('\n');
|
||||
const featureLinePattern = new RegExp(`^${feature}\\s*=\\s*(true|false)$`);
|
||||
let inFeatures = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '[features]') {
|
||||
inFeatures = true;
|
||||
continue;
|
||||
}
|
||||
if (inFeatures && trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
break;
|
||||
}
|
||||
if (!inFeatures) continue;
|
||||
const match = trimmed.match(featureLinePattern);
|
||||
if (match) return match[1] === 'true';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function readCodexFeatureFromFile(
|
||||
filePath: string,
|
||||
feature: CodexConfigFeature,
|
||||
): boolean {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
return readCodexFeatureFromContent(
|
||||
fs.readFileSync(filePath, 'utf-8'),
|
||||
feature,
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCodexFeatureInContent(
|
||||
content: string,
|
||||
feature: CodexConfigFeature,
|
||||
value: boolean,
|
||||
): string {
|
||||
const line = `${feature} = ${value}`;
|
||||
const re = new RegExp(`^\\s*${feature}\\s*=\\s*(true|false)\\s*$`, 'm');
|
||||
if (re.test(content)) {
|
||||
return content.replace(re, line);
|
||||
}
|
||||
if (/^\[features\]/m.test(content)) {
|
||||
return content.replace(/^\[features\]\s*$/m, `[features]\n${line}`);
|
||||
}
|
||||
const trimmed = content.replace(/\s*$/, '');
|
||||
if (!trimmed) {
|
||||
return `[features]\n${line}\n`;
|
||||
}
|
||||
return `${trimmed}\n\n[features]\n${line}\n`;
|
||||
}
|
||||
|
||||
export function writeCodexFeatureToFile(
|
||||
filePath: string,
|
||||
feature: CodexConfigFeature,
|
||||
value: boolean,
|
||||
): void {
|
||||
const content = fs.existsSync(filePath)
|
||||
? fs.readFileSync(filePath, 'utf-8')
|
||||
: '';
|
||||
const updated = writeCodexFeatureInContent(content, feature, value);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
245
src/codex-live-status.ts
Normal file
245
src/codex-live-status.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
export interface CodexRateLimitWindowSummary {
|
||||
limitWindowSeconds: number | null;
|
||||
resetAfterSeconds: number | null;
|
||||
resetAt: string | null;
|
||||
usedPercent: number | null;
|
||||
}
|
||||
|
||||
export interface CodexRateLimitSummary {
|
||||
allowed: boolean | null;
|
||||
limitReached: boolean | null;
|
||||
primaryWindow: CodexRateLimitWindowSummary | null;
|
||||
secondaryWindow: CodexRateLimitWindowSummary | null;
|
||||
}
|
||||
|
||||
export interface CodexAdditionalRateLimitSummary {
|
||||
limitName: string | null;
|
||||
meteredFeature: string | null;
|
||||
rateLimit: CodexRateLimitSummary | null;
|
||||
}
|
||||
|
||||
export interface CodexCreditsSummary {
|
||||
hasCredits: boolean | null;
|
||||
overageLimitReached: boolean | null;
|
||||
unlimited: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexSpendControlSummary {
|
||||
reached: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexLiveStatusSummary {
|
||||
checkedAt: string;
|
||||
source: 'wham/usage';
|
||||
planType: string | null;
|
||||
email: string | null;
|
||||
rateLimit: CodexRateLimitSummary | null;
|
||||
rateLimitReachedType: string | null;
|
||||
additionalRateLimits: CodexAdditionalRateLimitSummary[];
|
||||
credits: CodexCreditsSummary | null;
|
||||
spendControl: CodexSpendControlSummary | null;
|
||||
}
|
||||
|
||||
interface CodexLiveRateLimitWindow {
|
||||
limit_window_seconds?: number | null;
|
||||
reset_after_seconds?: number | null;
|
||||
reset_at?: number | null;
|
||||
used_percent?: number | null;
|
||||
}
|
||||
|
||||
interface CodexLiveRateLimit {
|
||||
allowed?: boolean | null;
|
||||
limit_reached?: boolean | null;
|
||||
primary_window?: CodexLiveRateLimitWindow | null;
|
||||
secondary_window?: CodexLiveRateLimitWindow | null;
|
||||
}
|
||||
|
||||
interface CodexAdditionalLiveRateLimit {
|
||||
limit_name?: string | null;
|
||||
metered_feature?: string | null;
|
||||
rate_limit?: CodexLiveRateLimit | null;
|
||||
}
|
||||
|
||||
interface CodexLiveCredits {
|
||||
has_credits?: boolean | null;
|
||||
overage_limit_reached?: boolean | null;
|
||||
unlimited?: boolean | null;
|
||||
}
|
||||
|
||||
interface CodexLiveSpendControl {
|
||||
reached?: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexLiveStatus {
|
||||
checked_at: string;
|
||||
plan_type?: string | null;
|
||||
email?: string | null;
|
||||
rate_limit?: CodexLiveRateLimit | null;
|
||||
rate_limit_reached_type?: string | null;
|
||||
additional_rate_limits?: CodexAdditionalLiveRateLimit[];
|
||||
credits?: CodexLiveCredits | null;
|
||||
spend_control?: CodexLiveSpendControl | null;
|
||||
}
|
||||
|
||||
function recordOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function booleanOrNull(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function epochSecondsToIso(value: number | null | undefined): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function normalizeRateLimitWindow(
|
||||
value: unknown,
|
||||
): CodexLiveRateLimitWindow | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
limit_window_seconds: numberOrNull(record.limit_window_seconds),
|
||||
reset_after_seconds: numberOrNull(record.reset_after_seconds),
|
||||
reset_at: numberOrNull(record.reset_at),
|
||||
used_percent: numberOrNull(record.used_percent),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRateLimit(value: unknown): CodexLiveRateLimit | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
allowed: booleanOrNull(record.allowed),
|
||||
limit_reached: booleanOrNull(record.limit_reached),
|
||||
primary_window: normalizeRateLimitWindow(record.primary_window),
|
||||
secondary_window: normalizeRateLimitWindow(record.secondary_window),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAdditionalRateLimits(
|
||||
value: unknown,
|
||||
): CodexAdditionalLiveRateLimit[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item): CodexAdditionalLiveRateLimit | null => {
|
||||
const record = recordOrNull(item);
|
||||
if (!record) return null;
|
||||
return {
|
||||
limit_name: stringOrNull(record.limit_name),
|
||||
metered_feature: stringOrNull(record.metered_feature),
|
||||
rate_limit: normalizeRateLimit(record.rate_limit),
|
||||
};
|
||||
})
|
||||
.filter((item): item is CodexAdditionalLiveRateLimit => item !== null);
|
||||
}
|
||||
|
||||
function normalizeCredits(value: unknown): CodexLiveCredits | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
has_credits: booleanOrNull(record.has_credits),
|
||||
overage_limit_reached: booleanOrNull(record.overage_limit_reached),
|
||||
unlimited: booleanOrNull(record.unlimited),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpendControl(value: unknown): CodexLiveSpendControl | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
reached: booleanOrNull(record.reached),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCodexLiveStatus(
|
||||
value: unknown,
|
||||
checkedAt: string,
|
||||
): CodexLiveStatus | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
checked_at: checkedAt,
|
||||
plan_type: stringOrNull(record.plan_type),
|
||||
email: stringOrNull(record.email),
|
||||
rate_limit: normalizeRateLimit(record.rate_limit),
|
||||
rate_limit_reached_type: stringOrNull(record.rate_limit_reached_type),
|
||||
additional_rate_limits: normalizeAdditionalRateLimits(
|
||||
record.additional_rate_limits,
|
||||
),
|
||||
credits: normalizeCredits(record.credits),
|
||||
spend_control: normalizeSpendControl(record.spend_control),
|
||||
};
|
||||
}
|
||||
|
||||
function toRateLimitWindowSummary(
|
||||
window: CodexLiveRateLimitWindow | null | undefined,
|
||||
): CodexRateLimitWindowSummary | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
limitWindowSeconds: window.limit_window_seconds ?? null,
|
||||
resetAfterSeconds: window.reset_after_seconds ?? null,
|
||||
resetAt: epochSecondsToIso(window.reset_at),
|
||||
usedPercent: window.used_percent ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toRateLimitSummary(
|
||||
rateLimit: CodexLiveRateLimit | null | undefined,
|
||||
): CodexRateLimitSummary | null {
|
||||
if (!rateLimit) return null;
|
||||
return {
|
||||
allowed: rateLimit.allowed ?? null,
|
||||
limitReached: rateLimit.limit_reached ?? null,
|
||||
primaryWindow: toRateLimitWindowSummary(rateLimit.primary_window),
|
||||
secondaryWindow: toRateLimitWindowSummary(rateLimit.secondary_window),
|
||||
};
|
||||
}
|
||||
|
||||
export function toCodexLiveStatusSummary(
|
||||
status: CodexLiveStatus,
|
||||
): CodexLiveStatusSummary {
|
||||
return {
|
||||
checkedAt: status.checked_at,
|
||||
source: 'wham/usage',
|
||||
planType: status.plan_type ?? null,
|
||||
email: status.email ?? null,
|
||||
rateLimit: toRateLimitSummary(status.rate_limit),
|
||||
rateLimitReachedType: status.rate_limit_reached_type ?? null,
|
||||
additionalRateLimits: (status.additional_rate_limits ?? []).map(
|
||||
(limit) => ({
|
||||
limitName: limit.limit_name ?? null,
|
||||
meteredFeature: limit.metered_feature ?? null,
|
||||
rateLimit: toRateLimitSummary(limit.rate_limit),
|
||||
}),
|
||||
),
|
||||
credits: status.credits
|
||||
? {
|
||||
hasCredits: status.credits.has_credits ?? null,
|
||||
overageLimitReached: status.credits.overage_limit_reached ?? null,
|
||||
unlimited: status.credits.unlimited ?? null,
|
||||
}
|
||||
: null,
|
||||
spendControl: status.spend_control
|
||||
? {
|
||||
reached: status.spend_control.reached ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('./agent-error-detection.js', () => ({
|
||||
classifyAgentError: vi.fn(() => ({ category: 'none', reason: '' })),
|
||||
classifyCodexAuthError: vi.fn(() => ({ category: 'none', reason: '' })),
|
||||
isCodexPoolUnavailableError: vi.fn(
|
||||
(error: string | null | undefined) =>
|
||||
/all\s+codex(?:\s+rotation)?\s+accounts(?:\s+are)?\s+unavailable/i.test(
|
||||
error ?? '',
|
||||
) || /codex\s+rotation\s+pool\s+unavailable/i.test(error ?? ''),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
@@ -165,6 +171,236 @@ describe('codex-token-rotation d7 ≥ 100% auto-skip', () => {
|
||||
expect(mod.getCodexAccountCount()).toBe(4);
|
||||
expect(mod.getActiveCodexAuthPath()).not.toBe(fallbackAuthPath);
|
||||
});
|
||||
|
||||
it('does not mutate account health for the internal all-accounts-unavailable sentinel', async () => {
|
||||
const agentErrors = await import('./agent-error-detection.js');
|
||||
vi.mocked(agentErrors.classifyCodexAuthError).mockReturnValueOnce({
|
||||
category: 'auth-expired',
|
||||
reason: 'auth-expired',
|
||||
});
|
||||
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
expect(
|
||||
mod.rotateCodexToken(
|
||||
'auth-expired: All Codex rotation accounts unavailable; re-auth required before launching Codex',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const accounts = mod.getAllCodexAccounts();
|
||||
expect(accounts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
isActive: true,
|
||||
isAuthDead: false,
|
||||
authStatus: 'healthy',
|
||||
isRateLimited: false,
|
||||
}),
|
||||
);
|
||||
expect(accounts[1].isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('marks refresh-token reuse as dead auth instead of a recoverable cooldown', async () => {
|
||||
const agentErrors = await import('./agent-error-detection.js');
|
||||
vi.mocked(agentErrors.classifyCodexAuthError).mockReturnValueOnce({
|
||||
category: 'auth-expired',
|
||||
reason: 'auth-expired',
|
||||
});
|
||||
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
expect(mod.rotateCodexToken('refresh token was already used')).toBe(true);
|
||||
|
||||
const accounts = mod.getAllCodexAccounts();
|
||||
expect(accounts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
isAuthDead: true,
|
||||
authStatus: 'dead_auth',
|
||||
}),
|
||||
);
|
||||
expect(accounts[0].isRateLimited).toBe(false);
|
||||
expect(accounts[1].isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex-token-rotation auth synchronization', () => {
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-rot-'));
|
||||
process.env.CODEX_ROT_TEST_HOME = tempHome;
|
||||
createFakeAccounts(tempHome, 4);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CODEX_ROT_TEST_HOME;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('recovers a dead_auth account when canonical auth.json is refreshed before lease claim', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||
try {
|
||||
const agentErrors = await import('./agent-error-detection.js');
|
||||
vi.mocked(agentErrors.classifyCodexAuthError).mockReturnValueOnce({
|
||||
category: 'auth-expired',
|
||||
reason: 'auth-expired',
|
||||
});
|
||||
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
const utils = await import('./utils.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
expect(mod.rotateCodexToken('refresh token was already used')).toBe(true);
|
||||
expect(mod.getAllCodexAccounts()[0].isAuthDead).toBe(true);
|
||||
|
||||
const refreshedAt = new Date('2026-01-01T00:00:10.000Z');
|
||||
const authPath = path.join(tempHome, '.codex-accounts', '0', 'auth.json');
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { account_id: 'acct-0', access_token: 'refreshed-token' },
|
||||
}),
|
||||
);
|
||||
fs.utimesSync(authPath, refreshedAt, refreshedAt);
|
||||
|
||||
mod.setCurrentCodexAccountIndex(0);
|
||||
const lease = mod.claimCodexAuthLease();
|
||||
try {
|
||||
expect(lease).toEqual(
|
||||
expect.objectContaining({ accountIndex: 0, authPath }),
|
||||
);
|
||||
expect(mod.getAllCodexAccounts()[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
authStatus: 'healthy',
|
||||
isAuthDead: false,
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(utils.writeJsonFile)).toHaveBeenCalled();
|
||||
} finally {
|
||||
lease?.release();
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leases different accounts for concurrent Codex runs', async () => {
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
const first = mod.claimCodexAuthLease();
|
||||
const second = mod.claimCodexAuthLease();
|
||||
|
||||
try {
|
||||
expect(first).toEqual(
|
||||
expect.objectContaining({
|
||||
accountIndex: 0,
|
||||
authPath: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(second).toEqual(
|
||||
expect.objectContaining({
|
||||
accountIndex: 1,
|
||||
authPath: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(second?.authPath).not.toBe(first?.authPath);
|
||||
} finally {
|
||||
second?.release();
|
||||
first?.release();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs a refreshed session auth.json back to the canonical slot atomically', async () => {
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
const canonicalAuthPath = path.join(
|
||||
tempHome,
|
||||
'.codex-accounts',
|
||||
'0',
|
||||
'auth.json',
|
||||
);
|
||||
const sessionAuthPath = path.join(
|
||||
tempHome,
|
||||
'session',
|
||||
'.codex',
|
||||
'auth.json',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(sessionAuthPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
sessionAuthPath,
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: {
|
||||
account_id: 'acct-0',
|
||||
access_token: 'new-access',
|
||||
refresh_token: 'new-refresh',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const synced = mod.syncCodexSessionAuthBack({
|
||||
canonicalAuthPath,
|
||||
sessionAuthPath,
|
||||
accountIndex: 0,
|
||||
});
|
||||
|
||||
expect(synced).toBe(true);
|
||||
const canonical = JSON.parse(fs.readFileSync(canonicalAuthPath, 'utf-8'));
|
||||
expect(canonical.tokens).toEqual(
|
||||
expect.objectContaining({
|
||||
account_id: 'acct-0',
|
||||
access_token: 'new-access',
|
||||
refresh_token: 'new-refresh',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to sync a session auth.json that belongs to another Codex account', async () => {
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
const canonicalAuthPath = path.join(
|
||||
tempHome,
|
||||
'.codex-accounts',
|
||||
'0',
|
||||
'auth.json',
|
||||
);
|
||||
const before = fs.readFileSync(canonicalAuthPath, 'utf-8');
|
||||
const sessionAuthPath = path.join(
|
||||
tempHome,
|
||||
'wrong-account',
|
||||
'.codex',
|
||||
'auth.json',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(sessionAuthPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
sessionAuthPath,
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: {
|
||||
account_id: 'acct-other',
|
||||
access_token: 'other-access',
|
||||
refresh_token: 'other-refresh',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
mod.syncCodexSessionAuthBack({
|
||||
canonicalAuthPath,
|
||||
sessionAuthPath,
|
||||
accountIndex: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(fs.readFileSync(canonicalAuthPath, 'utf-8')).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex-token-rotation single-account fallback', () => {
|
||||
|
||||
@@ -17,13 +17,13 @@ import path from 'path';
|
||||
import {
|
||||
classifyAgentError,
|
||||
classifyCodexAuthError,
|
||||
isCodexPoolUnavailableError,
|
||||
type CodexRotationReason,
|
||||
} from './agent-error-detection.js';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
computeCooldownUntil,
|
||||
findNextAvailable,
|
||||
parseRetryAfterFromError,
|
||||
} from './token-rotation-base.js';
|
||||
import { readJsonFile, writeJsonFile } from './utils.js';
|
||||
@@ -34,15 +34,27 @@ interface CodexAccount {
|
||||
index: number;
|
||||
authPath: string;
|
||||
accountId: string;
|
||||
authFileMtimeMs: number;
|
||||
planType: string;
|
||||
subscriptionUntil: string | null;
|
||||
rateLimitedUntil: number | null;
|
||||
authStatus: 'healthy' | 'dead_auth';
|
||||
authDeadAt: number | null;
|
||||
authDeadReason: string | null;
|
||||
leasedUntil: number | null;
|
||||
leaseId: string | null;
|
||||
lastUsagePct?: number;
|
||||
lastUsageD7Pct?: number;
|
||||
resetAt?: string;
|
||||
resetD7At?: string;
|
||||
}
|
||||
|
||||
export interface CodexAuthLease {
|
||||
accountIndex: number;
|
||||
authPath: string;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export type CodexRotationTriggerResult =
|
||||
| {
|
||||
shouldRotate: false;
|
||||
@@ -77,10 +89,20 @@ const accounts: CodexAccount[] = [];
|
||||
let currentIndex = 0;
|
||||
let initialized = false;
|
||||
|
||||
const CODEX_AUTH_LEASE_MS = 2 * 60 * 60_000;
|
||||
|
||||
const ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
|
||||
const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
|
||||
const DEFAULT_AUTH_PATH = path.join(DEFAULT_CODEX_DIR, 'auth.json');
|
||||
|
||||
function readAuthFileMtimeMs(authPath: string): number {
|
||||
try {
|
||||
return fs.statSync(authPath).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function loadCodexAccount(
|
||||
authPath: string,
|
||||
fallbackAccountId: string,
|
||||
@@ -99,9 +121,15 @@ function loadCodexAccount(
|
||||
index: accounts.length,
|
||||
authPath,
|
||||
accountId,
|
||||
authFileMtimeMs: readAuthFileMtimeMs(authPath),
|
||||
planType: jwt.planType,
|
||||
subscriptionUntil: jwt.expiresAt,
|
||||
rateLimitedUntil: null,
|
||||
authStatus: 'healthy',
|
||||
authDeadAt: null,
|
||||
authDeadReason: null,
|
||||
leasedUntil: null,
|
||||
leaseId: null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -149,6 +177,8 @@ function saveCodexState(): void {
|
||||
const state = {
|
||||
currentIndex,
|
||||
rateLimits: accounts.map((a) => a.rateLimitedUntil),
|
||||
authDeadAts: accounts.map((a) => a.authDeadAt),
|
||||
authDeadReasons: accounts.map((a) => a.authDeadReason),
|
||||
usagePcts: accounts.map((a) => a.lastUsagePct ?? null),
|
||||
usageD7Pcts: accounts.map((a) => a.lastUsageD7Pct ?? null),
|
||||
resetAts: accounts.map((a) => a.resetAt ?? null),
|
||||
@@ -167,6 +197,8 @@ function loadCodexState(quiet = false): void {
|
||||
const state = readJsonFile<{
|
||||
currentIndex?: number;
|
||||
rateLimits?: (number | null)[];
|
||||
authDeadAts?: (number | null)[];
|
||||
authDeadReasons?: (string | null)[];
|
||||
usagePcts?: (number | null)[];
|
||||
usageD7Pcts?: (number | null)[];
|
||||
resetAts?: (string | null)[];
|
||||
@@ -175,6 +207,7 @@ function loadCodexState(quiet = false): void {
|
||||
if (!state) return;
|
||||
|
||||
const now = Date.now();
|
||||
let restoredDeadAuth = false;
|
||||
if (
|
||||
typeof state.currentIndex === 'number' &&
|
||||
state.currentIndex < accounts.length
|
||||
@@ -195,6 +228,39 @@ function loadCodexState(quiet = false): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(state.authDeadAts)) {
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.min(state.authDeadAts.length, accounts.length);
|
||||
i++
|
||||
) {
|
||||
const deadAt = state.authDeadAts[i];
|
||||
const acct = accounts[i];
|
||||
if (typeof deadAt === 'number' && deadAt > 0) {
|
||||
const currentMtime = readAuthFileMtimeMs(acct.authPath);
|
||||
acct.authFileMtimeMs = currentMtime;
|
||||
if (currentMtime > deadAt) {
|
||||
acct.authStatus = 'dead_auth';
|
||||
acct.authDeadAt = deadAt;
|
||||
acct.authDeadReason = state.authDeadReasons?.[i] ?? 'auth-expired';
|
||||
restoredDeadAuth =
|
||||
restoreDeadAuthIfAuthFileChanged(acct) || restoredDeadAuth;
|
||||
} else {
|
||||
acct.authStatus = 'dead_auth';
|
||||
acct.authDeadAt = deadAt;
|
||||
acct.authDeadReason = state.authDeadReasons?.[i] ?? 'auth-expired';
|
||||
acct.rateLimitedUntil = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
currentIndex < accounts.length &&
|
||||
accounts[currentIndex]?.authStatus === 'dead_auth'
|
||||
) {
|
||||
const nextIdx = findNextCodexAvailable(currentIndex);
|
||||
if (nextIdx !== null) currentIndex = nextIdx;
|
||||
}
|
||||
if (Array.isArray(state.usagePcts)) {
|
||||
for (
|
||||
let i = 0;
|
||||
@@ -239,6 +305,7 @@ function loadCodexState(quiet = false): void {
|
||||
'Codex rotation state restored',
|
||||
);
|
||||
}
|
||||
if (restoredDeadAuth) saveCodexState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +323,52 @@ export function getActiveCodexAuthPath(): string | null {
|
||||
return getCodexAuthPath();
|
||||
}
|
||||
|
||||
/** Currently active codex account index (after rotation). */
|
||||
export function getCurrentCodexAccountIndex(): number {
|
||||
return accounts.length === 0 ? 0 : currentIndex;
|
||||
}
|
||||
|
||||
/** Find the rotation-array index that owns the given auth.json path. */
|
||||
export function findCodexAccountIndexByAuthPath(
|
||||
authPath: string,
|
||||
): number | null {
|
||||
for (let i = 0; i < accounts.length; i += 1) {
|
||||
if (accounts[i]?.authPath === authPath) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Manually switch the active codex account. Clears its rate-limit cooldown. */
|
||||
export function setCurrentCodexAccountIndex(targetIndex: number): void {
|
||||
if (accounts.length === 0) {
|
||||
throw new Error('codex token rotation: no accounts loaded');
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(targetIndex) ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= accounts.length
|
||||
) {
|
||||
throw new Error(
|
||||
`codex switch: index ${targetIndex} out of range [0..${accounts.length - 1}]`,
|
||||
);
|
||||
}
|
||||
if (targetIndex === currentIndex) return;
|
||||
const previous = currentIndex;
|
||||
currentIndex = targetIndex;
|
||||
// Clear rate-limit on the chosen account so rotation logic doesn't bounce it.
|
||||
accounts[targetIndex].rateLimitedUntil = null;
|
||||
saveCodexState();
|
||||
logger.info(
|
||||
{
|
||||
transition: 'rotation:manual-switch',
|
||||
fromIndex: previous,
|
||||
toIndex: currentIndex,
|
||||
totalAccounts: accounts.length,
|
||||
},
|
||||
`Codex switched manually to account #${currentIndex + 1}/${accounts.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getCodexAuthPath(
|
||||
accountIndex: number = currentIndex,
|
||||
): string | null {
|
||||
@@ -263,10 +376,272 @@ export function getCodexAuthPath(
|
||||
return accounts[accountIndex]?.authPath ?? null;
|
||||
}
|
||||
|
||||
function codexLockPath(authPath: string): string {
|
||||
return path.join(path.dirname(authPath), '.ejclaw-auth.lock');
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readExistingLease(lockPath: string): {
|
||||
leaseId?: string;
|
||||
pid?: number;
|
||||
expiresAt?: number;
|
||||
} | null {
|
||||
const data = readJsonFile<{
|
||||
leaseId?: string;
|
||||
pid?: number;
|
||||
expiresAt?: number;
|
||||
}>(lockPath);
|
||||
return data ?? null;
|
||||
}
|
||||
|
||||
function tryAcquireDiskLease(
|
||||
acct: CodexAccount,
|
||||
leaseId: string,
|
||||
now: number,
|
||||
): boolean {
|
||||
const lockPath = codexLockPath(acct.authPath);
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
leaseId,
|
||||
pid: process.pid,
|
||||
accountIndex: acct.index,
|
||||
createdAt: now,
|
||||
expiresAt: now + CODEX_AUTH_LEASE_MS,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false;
|
||||
}
|
||||
|
||||
const existing = readExistingLease(lockPath);
|
||||
const expired = Boolean(existing?.expiresAt && existing.expiresAt <= now);
|
||||
const deadPid = Boolean(existing?.pid && !isPidAlive(existing.pid));
|
||||
if (!expired && !deadPid) return false;
|
||||
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseDiskLease(authPath: string, leaseId: string): void {
|
||||
const lockPath = codexLockPath(authPath);
|
||||
const existing = readExistingLease(lockPath);
|
||||
if (existing?.leaseId && existing.leaseId !== leaseId) return;
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
// Best effort only. Expired/stale locks are cleared by the next claimant.
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexAccountUsable(
|
||||
acct: CodexAccount,
|
||||
now = Date.now(),
|
||||
opts?: { ignoreRateLimits?: boolean; ignoreD7?: boolean },
|
||||
): boolean {
|
||||
if (restoreDeadAuthIfAuthFileChanged(acct)) saveCodexState();
|
||||
if (acct.authStatus === 'dead_auth') return false;
|
||||
if (
|
||||
!opts?.ignoreRateLimits &&
|
||||
acct.rateLimitedUntil &&
|
||||
acct.rateLimitedUntil > now
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!opts?.ignoreD7 &&
|
||||
acct.lastUsageD7Pct != null &&
|
||||
acct.lastUsageD7Pct >= 100
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (acct.leasedUntil && acct.leasedUntil > now) return false;
|
||||
|
||||
const existing = readExistingLease(codexLockPath(acct.authPath));
|
||||
if (!existing) return true;
|
||||
if (existing.expiresAt && existing.expiresAt <= now) return true;
|
||||
if (existing.pid && !isPidAlive(existing.pid)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function restoreDeadAuthIfAuthFileChanged(acct: CodexAccount): boolean {
|
||||
if (acct.authStatus !== 'dead_auth' || !acct.authDeadAt) return false;
|
||||
const currentMtime = readAuthFileMtimeMs(acct.authPath);
|
||||
acct.authFileMtimeMs = currentMtime;
|
||||
if (currentMtime <= acct.authDeadAt) return false;
|
||||
|
||||
const previousDeadAt = acct.authDeadAt;
|
||||
acct.authStatus = 'healthy';
|
||||
acct.authDeadAt = null;
|
||||
acct.authDeadReason = null;
|
||||
acct.rateLimitedUntil = null;
|
||||
logger.info(
|
||||
{
|
||||
transition: 'rotation:auth-file-refreshed',
|
||||
accountIndex: acct.index,
|
||||
previousDeadAt: new Date(previousDeadAt).toISOString(),
|
||||
authFileMtime: new Date(currentMtime).toISOString(),
|
||||
},
|
||||
`Codex account #${acct.index + 1}/${accounts.length} marked healthy after auth.json refresh`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function markCodexAccountDeadAuth(acct: CodexAccount, reason?: string): void {
|
||||
acct.authStatus = 'dead_auth';
|
||||
acct.authDeadAt = Date.now();
|
||||
acct.authDeadReason = reason || 'auth-expired';
|
||||
acct.rateLimitedUntil = null;
|
||||
acct.leasedUntil = null;
|
||||
acct.leaseId = null;
|
||||
logger.warn(
|
||||
{
|
||||
transition: 'rotation:dead-auth',
|
||||
accountIndex: acct.index,
|
||||
accountId: acct.accountId,
|
||||
reason: acct.authDeadReason,
|
||||
},
|
||||
`Codex account #${acct.index + 1}/${accounts.length} marked dead; re-auth required`,
|
||||
);
|
||||
}
|
||||
|
||||
function updateAccountMetadataFromAuthFile(acct: CodexAccount): void {
|
||||
const data = readJsonFile<{
|
||||
tokens?: { account_id?: string; id_token?: string };
|
||||
}>(acct.authPath);
|
||||
if (data?.tokens?.account_id) acct.accountId = data.tokens.account_id;
|
||||
const jwt = parseJwtAuth(data?.tokens?.id_token || '');
|
||||
acct.planType = jwt.planType;
|
||||
acct.subscriptionUntil = jwt.expiresAt;
|
||||
acct.authFileMtimeMs = readAuthFileMtimeMs(acct.authPath);
|
||||
}
|
||||
|
||||
export function claimCodexAuthLease(): CodexAuthLease | null {
|
||||
if (accounts.length === 0) return null;
|
||||
const now = Date.now();
|
||||
const attempts = accounts.length;
|
||||
for (let offset = 0; offset < attempts; offset += 1) {
|
||||
const idx = (currentIndex + offset) % accounts.length;
|
||||
const acct = accounts[idx];
|
||||
if (!isCodexAccountUsable(acct, now)) continue;
|
||||
const leaseId = `${process.pid}-${now}-${idx}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
if (!tryAcquireDiskLease(acct, leaseId, now)) continue;
|
||||
currentIndex = idx;
|
||||
acct.leasedUntil = now + CODEX_AUTH_LEASE_MS;
|
||||
acct.leaseId = leaseId;
|
||||
return {
|
||||
accountIndex: idx,
|
||||
authPath: acct.authPath,
|
||||
release: () => {
|
||||
if (acct.leaseId === leaseId) {
|
||||
acct.leasedUntil = null;
|
||||
acct.leaseId = null;
|
||||
}
|
||||
releaseDiskLease(acct.authPath, leaseId);
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function syncCodexSessionAuthBack(args: {
|
||||
canonicalAuthPath: string;
|
||||
sessionAuthPath: string;
|
||||
accountIndex?: number | null;
|
||||
}): boolean {
|
||||
if (!fs.existsSync(args.sessionAuthPath)) return false;
|
||||
if (!fs.existsSync(args.canonicalAuthPath)) return false;
|
||||
|
||||
const canonical = readJsonFile<{
|
||||
auth_mode?: string;
|
||||
tokens?: { account_id?: string };
|
||||
}>(args.canonicalAuthPath);
|
||||
const session = readJsonFile<{
|
||||
auth_mode?: string;
|
||||
tokens?: { account_id?: string };
|
||||
}>(args.sessionAuthPath);
|
||||
if (!canonical || !session?.tokens) return false;
|
||||
|
||||
const canonicalAccount = canonical.tokens?.account_id;
|
||||
const sessionAccount = session.tokens.account_id;
|
||||
if (
|
||||
canonicalAccount &&
|
||||
sessionAccount &&
|
||||
canonicalAccount !== sessionAccount
|
||||
) {
|
||||
logger.warn(
|
||||
{
|
||||
canonicalAuthPath: args.canonicalAuthPath,
|
||||
sessionAuthPath: args.sessionAuthPath,
|
||||
accountIndex: args.accountIndex ?? null,
|
||||
},
|
||||
'Refusing to sync Codex session auth back: account mismatch',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionRaw = fs.readFileSync(args.sessionAuthPath, 'utf-8');
|
||||
const canonicalRaw = fs.readFileSync(args.canonicalAuthPath, 'utf-8');
|
||||
if (sessionRaw === canonicalRaw) return false;
|
||||
|
||||
const tmpPath = `${args.canonicalAuthPath}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tmpPath, sessionRaw, { mode: 0o600 });
|
||||
fs.renameSync(tmpPath, args.canonicalAuthPath);
|
||||
|
||||
const idx =
|
||||
args.accountIndex ??
|
||||
findCodexAccountIndexByAuthPath(args.canonicalAuthPath);
|
||||
if (idx != null && accounts[idx]) {
|
||||
const acct = accounts[idx];
|
||||
acct.authStatus = 'healthy';
|
||||
acct.authDeadAt = null;
|
||||
acct.authDeadReason = null;
|
||||
updateAccountMetadataFromAuthFile(acct);
|
||||
saveCodexState();
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
accountIndex: idx,
|
||||
canonicalAuthPath: args.canonicalAuthPath,
|
||||
},
|
||||
'Synced refreshed Codex session auth back to canonical account slot',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function detectCodexRotationTrigger(
|
||||
error?: string | null,
|
||||
): CodexRotationTriggerResult {
|
||||
if (!error) return { shouldRotate: false, reason: '' };
|
||||
if (isCodexPoolUnavailableError(error)) {
|
||||
return { shouldRotate: false, reason: '' };
|
||||
}
|
||||
|
||||
// Common patterns (429, 503, network) — delegated to SSOT
|
||||
const common = classifyAgentError(error);
|
||||
@@ -293,18 +668,38 @@ export function rotateCodexToken(
|
||||
): boolean {
|
||||
if (accounts.length <= 1) return false;
|
||||
|
||||
const previousIndex = currentIndex;
|
||||
const acct = accounts[currentIndex];
|
||||
const cooldownUntil = computeCooldownUntil(errorMessage);
|
||||
acct.rateLimitedUntil = cooldownUntil;
|
||||
acct.lastUsagePct = 100;
|
||||
// Extract reset time string from error for display
|
||||
const retryAt = parseRetryAfterFromError(errorMessage);
|
||||
if (retryAt) {
|
||||
acct.resetAt = new Date(retryAt).toISOString();
|
||||
if (isCodexPoolUnavailableError(errorMessage)) {
|
||||
logger.warn(
|
||||
{
|
||||
transition: 'rotation:skip-pool-unavailable-sentinel',
|
||||
currentIndex,
|
||||
totalAccounts: accounts.length,
|
||||
reason: errorMessage ?? null,
|
||||
},
|
||||
'Refusing to mark Codex accounts unhealthy from internal pool-unavailable sentinel',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextIdx = findNextAvailable(accounts, currentIndex, opts);
|
||||
const previousIndex = currentIndex;
|
||||
const acct = accounts[currentIndex];
|
||||
const authFailure = classifyCodexAuthError(errorMessage);
|
||||
let cooldownUntil: number | null = null;
|
||||
|
||||
if (authFailure.category === 'auth-expired') {
|
||||
markCodexAccountDeadAuth(acct, errorMessage || authFailure.reason);
|
||||
} else {
|
||||
cooldownUntil = computeCooldownUntil(errorMessage);
|
||||
acct.rateLimitedUntil = cooldownUntil;
|
||||
acct.lastUsagePct = 100;
|
||||
// Extract reset time string from error for display
|
||||
const retryAt = parseRetryAfterFromError(errorMessage);
|
||||
if (retryAt) {
|
||||
acct.resetAt = new Date(retryAt).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
const nextIdx = findNextCodexAvailable(currentIndex, opts);
|
||||
if (nextIdx !== null) {
|
||||
accounts[nextIdx].rateLimitedUntil = null;
|
||||
currentIndex = nextIdx;
|
||||
@@ -318,6 +713,7 @@ export function rotateCodexToken(
|
||||
ignoreRL: opts?.ignoreRateLimits ?? false,
|
||||
cooldownUntil:
|
||||
cooldownUntil != null ? new Date(cooldownUntil).toISOString() : null,
|
||||
authDead: authFailure.category === 'auth-expired',
|
||||
reason: errorMessage ?? null,
|
||||
},
|
||||
`Codex rotated to account #${currentIndex + 1}/${accounts.length}`,
|
||||
@@ -334,28 +730,40 @@ export function rotateCodexToken(
|
||||
ignoreRL: opts?.ignoreRateLimits ?? false,
|
||||
cooldownUntil:
|
||||
cooldownUntil != null ? new Date(cooldownUntil).toISOString() : null,
|
||||
authDead: authFailure.category === 'auth-expired',
|
||||
reason: errorMessage ?? null,
|
||||
},
|
||||
'All Codex accounts are rate-limited',
|
||||
authFailure.category === 'auth-expired'
|
||||
? 'All Codex accounts unavailable after auth failure; re-auth required'
|
||||
: 'All Codex accounts are rate-limited',
|
||||
);
|
||||
saveCodexState();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next Codex account that is neither rate-limited nor 7d-exhausted.
|
||||
*/
|
||||
function findNextCodexAvailable(fromIndex?: number): number | null {
|
||||
function findNextCodexAvailable(
|
||||
fromIndex?: number,
|
||||
opts?: { ignoreRateLimits?: boolean },
|
||||
): number | null {
|
||||
const now = Date.now();
|
||||
const start = fromIndex ?? currentIndex;
|
||||
for (let i = 1; i < accounts.length; i++) {
|
||||
const idx = (start + i) % accounts.length;
|
||||
const acct = accounts[idx];
|
||||
const rlOk = !acct.rateLimitedUntil || acct.rateLimitedUntil <= now;
|
||||
const usageOk = acct.lastUsageD7Pct == null || acct.lastUsageD7Pct < 100;
|
||||
if (rlOk && usageOk) return idx;
|
||||
if (isCodexAccountUsable(acct, now, opts)) return idx;
|
||||
}
|
||||
// All exhausted — fall back to rate-limit-only check
|
||||
return findNextAvailable(accounts, start);
|
||||
// All d7-exhausted — fall back to rate-limit/dead/lease checks only.
|
||||
for (let i = 1; i < accounts.length; i++) {
|
||||
const idx = (start + i) % accounts.length;
|
||||
const acct = accounts[idx];
|
||||
if (isCodexAccountUsable(acct, now, { ...opts, ignoreD7: true })) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,9 +831,17 @@ export function updateCodexAccountUsage(
|
||||
export function markCodexTokenHealthy(): void {
|
||||
if (accounts.length === 0) return;
|
||||
const acct = accounts[currentIndex];
|
||||
let changed = false;
|
||||
if (acct?.authStatus === 'dead_auth') {
|
||||
acct.authStatus = 'healthy';
|
||||
acct.authDeadAt = null;
|
||||
acct.authDeadReason = null;
|
||||
changed = true;
|
||||
}
|
||||
if (acct?.rateLimitedUntil) {
|
||||
const previousCooldownUntil = acct.rateLimitedUntil;
|
||||
acct.rateLimitedUntil = null;
|
||||
changed = true;
|
||||
logger.info(
|
||||
{
|
||||
transition: 'rotation:clear-rate-limit',
|
||||
@@ -435,8 +851,8 @@ export function markCodexTokenHealthy(): void {
|
||||
},
|
||||
'Cleared Codex account rate-limit state after successful response',
|
||||
);
|
||||
saveCodexState();
|
||||
}
|
||||
if (changed) saveCodexState();
|
||||
}
|
||||
|
||||
export function getCodexAccountCount(): number {
|
||||
@@ -449,6 +865,10 @@ export function getAllCodexAccounts(): {
|
||||
planType: string;
|
||||
isActive: boolean;
|
||||
isRateLimited: boolean;
|
||||
isAuthDead?: boolean;
|
||||
authStatus?: 'healthy' | 'dead_auth';
|
||||
authDeadAt?: string;
|
||||
isLeased?: boolean;
|
||||
cachedUsagePct?: number;
|
||||
cachedUsageD7Pct?: number;
|
||||
resetAt?: string;
|
||||
@@ -461,6 +881,10 @@ export function getAllCodexAccounts(): {
|
||||
planType: a.planType,
|
||||
isActive: i === currentIndex,
|
||||
isRateLimited: Boolean(a.rateLimitedUntil && a.rateLimitedUntil > now),
|
||||
isAuthDead: a.authStatus === 'dead_auth',
|
||||
authStatus: a.authStatus,
|
||||
authDeadAt: a.authDeadAt ? new Date(a.authDeadAt).toISOString() : undefined,
|
||||
isLeased: Boolean(a.leasedUntil && a.leasedUntil > now),
|
||||
cachedUsagePct: a.lastUsagePct,
|
||||
cachedUsageD7Pct: a.lastUsageD7Pct,
|
||||
resetAt: a.resetAt,
|
||||
|
||||
@@ -300,13 +300,9 @@ export function applyCodexUsageToAccount(
|
||||
|
||||
// Select the effective bucket
|
||||
const primaryBucket = usage.find((l) => l.limitId === 'codex');
|
||||
let effective: CodexRateLimit | null = null;
|
||||
const effective = primaryBucket ?? (usage.length === 1 ? usage[0] : null);
|
||||
|
||||
if (primaryBucket) {
|
||||
effective = primaryBucket;
|
||||
} else if (usage.length === 1) {
|
||||
effective = usage[0];
|
||||
} else {
|
||||
if (!effective) {
|
||||
// Multiple unknown buckets — cannot determine which is authoritative
|
||||
logger.warn(
|
||||
{ account: accountIndex + 1 },
|
||||
|
||||
104
src/compact-refresh.test.ts
Normal file
104
src/compact-refresh.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import fs from 'fs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/ejclaw-compact-refresh-test',
|
||||
}));
|
||||
|
||||
import {
|
||||
clearCompactRefreshIfUnchanged,
|
||||
markCompactRefreshNeeded,
|
||||
maybeApplyCompactRefresh,
|
||||
readCompactRefreshFlag,
|
||||
} from './compact-refresh.js';
|
||||
|
||||
const refreshDir = '/tmp/ejclaw-compact-refresh-test/compact-refresh';
|
||||
|
||||
describe('compact-refresh', () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(refreshDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('marks and reads compact refresh flags by session folder', () => {
|
||||
const flag = markCompactRefreshNeeded({
|
||||
sessionFolder: 'room:reviewer',
|
||||
sessionId: 'session-1',
|
||||
trigger: 'auto',
|
||||
});
|
||||
|
||||
expect(readCompactRefreshFlag('room:reviewer')).toMatchObject({
|
||||
sessionFolder: 'room:reviewer',
|
||||
sessionId: 'session-1',
|
||||
trigger: 'auto',
|
||||
compactedAt: flag.compactedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a one-shot compact refresh only for owner and reviewer roles', () => {
|
||||
const flag = markCompactRefreshNeeded({
|
||||
sessionFolder: 'room',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
const applied = maybeApplyCompactRefresh({
|
||||
sessionFolder: 'room',
|
||||
sessionId: 'session-1',
|
||||
role: 'owner',
|
||||
prompt: 'continue task',
|
||||
});
|
||||
|
||||
expect(applied?.flag).toEqual(flag);
|
||||
expect(applied?.prompt).toContain('EJClaw compact refresh');
|
||||
expect(applied?.prompt).toContain('continue task');
|
||||
|
||||
clearCompactRefreshIfUnchanged({
|
||||
sessionFolder: 'room',
|
||||
flag,
|
||||
});
|
||||
|
||||
expect(readCompactRefreshFlag('room')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not apply to arbiter, compact commands, fresh sessions, or stale sessions', () => {
|
||||
markCompactRefreshNeeded({
|
||||
sessionFolder: 'room',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeApplyCompactRefresh({
|
||||
sessionFolder: 'room',
|
||||
sessionId: 'session-1',
|
||||
role: 'arbiter',
|
||||
prompt: 'judge',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
maybeApplyCompactRefresh({
|
||||
sessionFolder: 'room',
|
||||
sessionId: 'session-1',
|
||||
role: 'owner',
|
||||
prompt: '/compact',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
maybeApplyCompactRefresh({
|
||||
sessionFolder: 'room',
|
||||
role: 'owner',
|
||||
prompt: 'fresh',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
maybeApplyCompactRefresh({
|
||||
sessionFolder: 'room',
|
||||
sessionId: 'different-session',
|
||||
role: 'owner',
|
||||
prompt: 'stale',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(readCompactRefreshFlag('room')).toBeNull();
|
||||
});
|
||||
});
|
||||
128
src/compact-refresh.ts
Normal file
128
src/compact-refresh.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import type { PairedRoomRole } from './types.js';
|
||||
|
||||
export interface CompactRefreshFlag {
|
||||
sessionFolder: string;
|
||||
sessionId: string;
|
||||
compactedAt: string;
|
||||
trigger?: string | null;
|
||||
}
|
||||
|
||||
export interface AppliedCompactRefresh {
|
||||
flag: CompactRefreshFlag;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const COMPACT_REFRESH_DIR = path.join(DATA_DIR, 'compact-refresh');
|
||||
|
||||
const COMPACT_REFRESH_PROMPT = `[EJClaw compact refresh]
|
||||
The previous run compacted this same SDK session. Continue following the current AGENTS.md/CLAUDE.md role rules exactly: required first-line status, paired-room role boundaries, output-only task context, concise Korean responses, and verification before completion. This is not new user scope.
|
||||
[/EJClaw compact refresh]`;
|
||||
|
||||
function getFlagPath(sessionFolder: string): string {
|
||||
return path.join(
|
||||
COMPACT_REFRESH_DIR,
|
||||
`${encodeURIComponent(sessionFolder)}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function isRefreshableRole(role: PairedRoomRole): boolean {
|
||||
return role === 'owner' || role === 'reviewer';
|
||||
}
|
||||
|
||||
function isSessionCommand(prompt: string): boolean {
|
||||
return prompt.trim() === '/compact';
|
||||
}
|
||||
|
||||
export function readCompactRefreshFlag(
|
||||
sessionFolder: string,
|
||||
): CompactRefreshFlag | null {
|
||||
const flagPath = getFlagPath(sessionFolder);
|
||||
if (!fs.existsSync(flagPath)) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(flagPath, 'utf-8'),
|
||||
) as Partial<CompactRefreshFlag>;
|
||||
if (
|
||||
parsed.sessionFolder !== sessionFolder ||
|
||||
typeof parsed.sessionId !== 'string' ||
|
||||
parsed.sessionId.length === 0 ||
|
||||
typeof parsed.compactedAt !== 'string'
|
||||
) {
|
||||
fs.rmSync(flagPath, { force: true });
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sessionFolder,
|
||||
sessionId: parsed.sessionId,
|
||||
compactedAt: parsed.compactedAt,
|
||||
trigger:
|
||||
typeof parsed.trigger === 'string' && parsed.trigger.length > 0
|
||||
? parsed.trigger
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
fs.rmSync(flagPath, { force: true });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function markCompactRefreshNeeded(args: {
|
||||
sessionFolder: string;
|
||||
sessionId: string;
|
||||
trigger?: string | null;
|
||||
}): CompactRefreshFlag {
|
||||
const flag: CompactRefreshFlag = {
|
||||
sessionFolder: args.sessionFolder,
|
||||
sessionId: args.sessionId,
|
||||
compactedAt: new Date().toISOString(),
|
||||
trigger: args.trigger ?? null,
|
||||
};
|
||||
fs.mkdirSync(COMPACT_REFRESH_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
getFlagPath(args.sessionFolder),
|
||||
`${JSON.stringify(flag)}\n`,
|
||||
);
|
||||
return flag;
|
||||
}
|
||||
|
||||
export function clearCompactRefreshIfUnchanged(args: {
|
||||
sessionFolder: string;
|
||||
flag: CompactRefreshFlag;
|
||||
}): void {
|
||||
const current = readCompactRefreshFlag(args.sessionFolder);
|
||||
if (
|
||||
current &&
|
||||
current.sessionId === args.flag.sessionId &&
|
||||
current.compactedAt === args.flag.compactedAt
|
||||
) {
|
||||
fs.rmSync(getFlagPath(args.sessionFolder), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function maybeApplyCompactRefresh(args: {
|
||||
sessionFolder: string;
|
||||
sessionId?: string;
|
||||
role: PairedRoomRole;
|
||||
prompt: string;
|
||||
}): AppliedCompactRefresh | null {
|
||||
if (!args.sessionId) return null;
|
||||
if (!isRefreshableRole(args.role)) return null;
|
||||
if (isSessionCommand(args.prompt)) return null;
|
||||
|
||||
const flag = readCompactRefreshFlag(args.sessionFolder);
|
||||
if (!flag) return null;
|
||||
if (flag.sessionId !== args.sessionId) {
|
||||
fs.rmSync(getFlagPath(args.sessionFolder), { force: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
flag,
|
||||
prompt: `${COMPACT_REFRESH_PROMPT}\n\n${args.prompt}`,
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,7 @@ describe('config/env loading', () => {
|
||||
delete process.env.WEB_DASHBOARD_HOST;
|
||||
delete process.env.WEB_DASHBOARD_PORT;
|
||||
delete process.env.WEB_DASHBOARD_STATIC_DIR;
|
||||
delete process.env.WEB_DASHBOARD_TOKEN;
|
||||
delete process.env.SESSION_COMMAND_USER_IDS;
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -175,12 +176,14 @@ describe('config/env loading', () => {
|
||||
expect(config.WEB_DASHBOARD.staticDir).toBe(
|
||||
path.resolve(tempRoot, 'apps', 'dashboard', 'dist'),
|
||||
);
|
||||
expect(config.WEB_DASHBOARD.token).toBe('');
|
||||
|
||||
vi.resetModules();
|
||||
process.env.WEB_DASHBOARD_ENABLED = 'true';
|
||||
process.env.WEB_DASHBOARD_HOST = '0.0.0.0';
|
||||
process.env.WEB_DASHBOARD_PORT = '9001';
|
||||
process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist';
|
||||
process.env.WEB_DASHBOARD_TOKEN = 'mobile-secret';
|
||||
config = await import('./config.js');
|
||||
|
||||
expect(config.WEB_DASHBOARD).toEqual({
|
||||
@@ -188,6 +191,7 @@ describe('config/env loading', () => {
|
||||
host: '0.0.0.0',
|
||||
port: 9001,
|
||||
staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'),
|
||||
token: 'mobile-secret',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -135,6 +135,34 @@ function assertNoLegacyEnvAliasesConfigured(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function buildPathsConfig(
|
||||
projectRoot: string,
|
||||
homeDir: string,
|
||||
): AppConfig['paths'] {
|
||||
return {
|
||||
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'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
assertNoLegacyEnvAliasesConfigured();
|
||||
|
||||
@@ -176,29 +204,7 @@ export function loadConfig(): AppConfig {
|
||||
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'),
|
||||
),
|
||||
},
|
||||
paths: buildPathsConfig(projectRoot, homeDir),
|
||||
runtime: {
|
||||
pollInterval: 2000,
|
||||
schedulerPollInterval: 60000,
|
||||
@@ -268,6 +274,7 @@ export function loadConfig(): AppConfig {
|
||||
readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ??
|
||||
path.join(projectRoot, 'apps', 'dashboard', 'dist'),
|
||||
),
|
||||
token: readNonEmptyText('WEB_DASHBOARD_TOKEN') ?? '',
|
||||
},
|
||||
codexWarmup: {
|
||||
enabled: readBoolean('CODEX_WARMUP_ENABLED', false),
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface AppConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
staticDir: string;
|
||||
token: string;
|
||||
};
|
||||
codexWarmup: {
|
||||
enabled: boolean;
|
||||
|
||||
88
src/dashboard-message-cleanup.test.ts
Normal file
88
src/dashboard-message-cleanup.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
cleanupDashboardDuplicateMessages,
|
||||
purgeDashboardMessages,
|
||||
} from './dashboard-message-cleanup.js';
|
||||
|
||||
function makeChannel(deleteRecentMessagesByContent: any) {
|
||||
return {
|
||||
name: 'discord',
|
||||
connect: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
isConnected: () => true,
|
||||
ownsJid: () => true,
|
||||
disconnect: vi.fn(),
|
||||
deleteRecentMessagesByContent,
|
||||
};
|
||||
}
|
||||
|
||||
describe('cleanupDashboardDuplicateMessages', () => {
|
||||
it('deletes recent dashboard messages except the tracked status message', async () => {
|
||||
const deleteRecentMessagesByContent = vi.fn(async () => 3);
|
||||
const deleted = await cleanupDashboardDuplicateMessages(
|
||||
{
|
||||
statusChannelId: 'status-channel',
|
||||
channels: [makeChannel(deleteRecentMessagesByContent)],
|
||||
},
|
||||
'tracked-message',
|
||||
);
|
||||
|
||||
expect(deleted).toBe(3);
|
||||
expect(deleteRecentMessagesByContent).toHaveBeenCalledWith(
|
||||
'dc:status-channel',
|
||||
{
|
||||
contentIncludes: '🤖 *모델 구성*',
|
||||
exceptMessageId: 'tracked-message',
|
||||
limit: 100,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('skips cleanup when there is no tracked status message yet', async () => {
|
||||
const deleteRecentMessagesByContent = vi.fn(async () => 3);
|
||||
const deleted = await cleanupDashboardDuplicateMessages(
|
||||
{
|
||||
statusChannelId: 'status-channel',
|
||||
channels: [makeChannel(deleteRecentMessagesByContent)],
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
expect(deleteRecentMessagesByContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('purgeDashboardMessages', () => {
|
||||
it('deletes dashboard marker messages without purging the whole channel', async () => {
|
||||
const deleteRecentMessagesByContent = vi.fn(async () => 2);
|
||||
const deleted = await purgeDashboardMessages({
|
||||
statusChannelId: 'status-channel',
|
||||
channels: [makeChannel(deleteRecentMessagesByContent)],
|
||||
});
|
||||
|
||||
expect(deleted).toBe(2);
|
||||
expect(deleteRecentMessagesByContent).toHaveBeenCalledWith(
|
||||
'dc:status-channel',
|
||||
{
|
||||
contentIncludes: '🤖 *모델 구성*',
|
||||
limit: 100,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('continues deleting dashboard batches until the last batch is partial', async () => {
|
||||
const deleteRecentMessagesByContent = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(100)
|
||||
.mockResolvedValueOnce(7);
|
||||
const deleted = await purgeDashboardMessages({
|
||||
statusChannelId: 'status-channel',
|
||||
channels: [makeChannel(deleteRecentMessagesByContent)],
|
||||
});
|
||||
|
||||
expect(deleted).toBe(107);
|
||||
expect(deleteRecentMessagesByContent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
67
src/dashboard-message-cleanup.ts
Normal file
67
src/dashboard-message-cleanup.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { logger } from './logger.js';
|
||||
import type { Channel } from './types.js';
|
||||
|
||||
export const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*';
|
||||
const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100;
|
||||
|
||||
function findDashboardCleanupChannel(opts: {
|
||||
channels: Channel[];
|
||||
statusChannelId: string;
|
||||
}): Channel | undefined {
|
||||
if (!opts.statusChannelId) return undefined;
|
||||
return opts.channels.find(
|
||||
(item) =>
|
||||
item.name.startsWith('discord') &&
|
||||
item.isConnected() &&
|
||||
item.deleteRecentMessagesByContent,
|
||||
);
|
||||
}
|
||||
|
||||
export async function purgeDashboardMessages(opts: {
|
||||
channels: Channel[];
|
||||
statusChannelId: string;
|
||||
}): Promise<number> {
|
||||
const channel = findDashboardCleanupChannel(opts);
|
||||
if (!channel?.deleteRecentMessagesByContent) return 0;
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const deleted = await channel.deleteRecentMessagesByContent(
|
||||
`dc:${opts.statusChannelId}`,
|
||||
{
|
||||
contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER,
|
||||
limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT,
|
||||
},
|
||||
);
|
||||
total += deleted;
|
||||
if (deleted < DASHBOARD_DUPLICATE_CLEANUP_LIMIT) break;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export async function cleanupDashboardDuplicateMessages(
|
||||
opts: { channels: Channel[]; statusChannelId: string },
|
||||
keepMessageId: string | null,
|
||||
): Promise<number> {
|
||||
if (!opts.statusChannelId || !keepMessageId) return 0;
|
||||
|
||||
const channel = findDashboardCleanupChannel(opts);
|
||||
if (!channel?.deleteRecentMessagesByContent) return 0;
|
||||
|
||||
try {
|
||||
return await channel.deleteRecentMessagesByContent(
|
||||
`dc:${opts.statusChannelId}`,
|
||||
{
|
||||
contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER,
|
||||
exceptMessageId: keepMessageId,
|
||||
limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, keepMessageId },
|
||||
'Dashboard duplicate message cleanup failed',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
84
src/db-attachment-payload.test.ts
Normal file
84
src/db-attachment-payload.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { parseAttachmentPayload } from './db/work-items.js';
|
||||
|
||||
describe('attachment payload parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(logger.warn).mockReset();
|
||||
});
|
||||
|
||||
it('logs malformed JSON without throwing or breaking old rows', () => {
|
||||
const attachments = parseAttachmentPayload('{"path":', {
|
||||
table: 'work_items',
|
||||
rowId: 42,
|
||||
});
|
||||
|
||||
expect(attachments).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
table: 'work_items',
|
||||
rowId: 42,
|
||||
reason: 'invalid_json',
|
||||
payloadLength: 8,
|
||||
payloadPreview: '{"path":',
|
||||
err: expect.any(SyntaxError),
|
||||
}),
|
||||
'Ignored malformed attachment payload',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs non-array attachment JSON without throwing', () => {
|
||||
const payload = '{"path":"/tmp/image.png"}';
|
||||
const attachments = parseAttachmentPayload(payload, {
|
||||
table: 'paired_turn_outputs',
|
||||
rowId: 7,
|
||||
});
|
||||
|
||||
expect(attachments).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
table: 'paired_turn_outputs',
|
||||
rowId: 7,
|
||||
reason: 'not_array',
|
||||
payloadLength: payload.length,
|
||||
payloadPreview: payload,
|
||||
}),
|
||||
'Ignored malformed attachment payload',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps valid attachments while logging malformed entries', () => {
|
||||
const payload = JSON.stringify([
|
||||
{ path: '/tmp/image.png', name: 'image.png', mime: 'image/png' },
|
||||
{ name: 'missing-path.png' },
|
||||
null,
|
||||
]);
|
||||
|
||||
const attachments = parseAttachmentPayload(payload, {
|
||||
table: 'work_items',
|
||||
rowId: 43,
|
||||
});
|
||||
|
||||
expect(attachments).toEqual([
|
||||
{ path: '/tmp/image.png', name: 'image.png', mime: 'image/png' },
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
table: 'work_items',
|
||||
rowId: 43,
|
||||
invalidEntryCount: 2,
|
||||
validEntryCount: 1,
|
||||
payloadLength: payload.length,
|
||||
payloadPreview: payload,
|
||||
}),
|
||||
'Ignored invalid attachment payload entries',
|
||||
);
|
||||
});
|
||||
});
|
||||
177
src/db-evidence.test.ts
Normal file
177
src/db-evidence.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { _initTestDatabase, insertPairedTurnOutput } from './db.js';
|
||||
import { requireDatabase } from './db/runtime-database.js';
|
||||
import {
|
||||
normalizeDbEvidenceLimit,
|
||||
normalizeDbEvidenceMinutes,
|
||||
normalizeDbEvidenceTaskId,
|
||||
runDbEvidenceRequest,
|
||||
} from './db-evidence.js';
|
||||
|
||||
describe('DB evidence presets', () => {
|
||||
function seedPairedTask(): void {
|
||||
_initTestDatabase();
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_tasks (
|
||||
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
|
||||
owner_agent_type, reviewer_agent_type, arbiter_agent_type,
|
||||
status, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'task-1',
|
||||
'room-1',
|
||||
'room-folder',
|
||||
'codex-main',
|
||||
'claude',
|
||||
'codex',
|
||||
'claude-code',
|
||||
'codex',
|
||||
'active',
|
||||
'2026-05-26T00:00:00.000Z',
|
||||
'2026-05-26T00:10:00.000Z',
|
||||
);
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turns (
|
||||
turn_id, task_id, task_updated_at, role, intent_kind, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'turn-1',
|
||||
'task-1',
|
||||
'2026-05-26T00:10:00.000Z',
|
||||
'owner',
|
||||
'owner-turn',
|
||||
'2026-05-26T00:00:01.000Z',
|
||||
'2026-05-26T00:00:02.000Z',
|
||||
);
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_turn_attempts (
|
||||
attempt_id, turn_id, attempt_no, task_id, task_updated_at, role, intent_kind,
|
||||
state, executor_service_id, executor_agent_type, active_run_id,
|
||||
created_at, updated_at, completed_at, last_error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'turn-1:attempt:1',
|
||||
'turn-1',
|
||||
1,
|
||||
'task-1',
|
||||
'2026-05-26T00:10:00.000Z',
|
||||
'owner',
|
||||
'owner-turn',
|
||||
'failed',
|
||||
'codex-main',
|
||||
'codex',
|
||||
null,
|
||||
'2026-05-26T00:00:01.000Z',
|
||||
'2026-05-26T00:00:03.000Z',
|
||||
'2026-05-26T00:00:04.000Z',
|
||||
'failure with sk-12345678901234567890',
|
||||
);
|
||||
insertPairedTurnOutput(
|
||||
'task-1',
|
||||
1,
|
||||
'owner',
|
||||
'SECRET USER TEXT SHOULD NOT BE RETURNED',
|
||||
'2026-05-26T00:00:05.000Z',
|
||||
);
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO work_items (
|
||||
group_folder, chat_jid, agent_type, service_id, delivery_role,
|
||||
status, start_seq, end_seq, result_payload, delivery_attempts,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'room-folder',
|
||||
'room-1',
|
||||
'codex',
|
||||
'codex-main',
|
||||
'owner',
|
||||
'produced',
|
||||
null,
|
||||
null,
|
||||
'raw delivery body should not be returned',
|
||||
0,
|
||||
'2026-05-26T00:00:06.000Z',
|
||||
'2026-05-26T00:00:06.000Z',
|
||||
);
|
||||
}
|
||||
|
||||
it('normalizes request bounds and validates task ids', () => {
|
||||
expect(normalizeDbEvidenceMinutes()).toBe(60);
|
||||
expect(normalizeDbEvidenceMinutes(0)).toBe(1);
|
||||
expect(normalizeDbEvidenceMinutes(99999)).toBe(1440);
|
||||
expect(normalizeDbEvidenceLimit()).toBe(20);
|
||||
expect(normalizeDbEvidenceLimit(999)).toBe(100);
|
||||
expect(normalizeDbEvidenceTaskId(' task-1 ')).toBe('task-1');
|
||||
expect(() => normalizeDbEvidenceTaskId('../bad task')).toThrow(
|
||||
'Unsupported paired task id',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns task status and flow metadata without raw bodies', () => {
|
||||
seedPairedTask();
|
||||
|
||||
const status = JSON.parse(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||
{ sourceGroup: 'room-folder', isMain: false },
|
||||
),
|
||||
) as { task: { id: string; group_folder: string } };
|
||||
expect(status.task.id).toBe('task-1');
|
||||
expect(status.task.group_folder).toBe('room-folder');
|
||||
|
||||
const flowText = runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_flow', taskId: 'task-1' },
|
||||
{ sourceGroup: 'room-folder', isMain: false },
|
||||
);
|
||||
expect(flowText).toContain('"output_chars"');
|
||||
expect(flowText).toContain('"last_error_chars"');
|
||||
expect(flowText).not.toContain('SECRET USER TEXT');
|
||||
expect(flowText).not.toContain('raw delivery body');
|
||||
expect(flowText).not.toContain('sk-12345678901234567890');
|
||||
});
|
||||
|
||||
it('scopes non-main DB evidence to the source group', () => {
|
||||
seedPairedTask();
|
||||
|
||||
const blocked = JSON.parse(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||
{ sourceGroup: 'other-folder', isMain: false },
|
||||
),
|
||||
) as { task: unknown };
|
||||
expect(blocked.task).toBeNull();
|
||||
|
||||
const main = JSON.parse(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_paired_task_status', taskId: 'task-1' },
|
||||
{ sourceGroup: 'other-folder', isMain: true },
|
||||
),
|
||||
) as { task: { id: string } };
|
||||
expect(main.task.id).toBe('task-1');
|
||||
});
|
||||
});
|
||||
495
src/db-evidence.ts
Normal file
495
src/db-evidence.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import {
|
||||
DB_EVIDENCE_ACTIONS,
|
||||
isDbEvidenceAction,
|
||||
type DbEvidenceAction,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
import { parseGitHubCiMetadata } from './github-ci.js';
|
||||
import { extractWatchCiTarget } from './task-watch-status.js';
|
||||
|
||||
type SqlBinding = string | number | bigint | boolean | null | Uint8Array;
|
||||
|
||||
export { DB_EVIDENCE_ACTIONS, isDbEvidenceAction, type DbEvidenceAction };
|
||||
|
||||
export interface DbEvidenceRequest {
|
||||
action: DbEvidenceAction;
|
||||
taskId?: string;
|
||||
minutes?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface DbEvidenceScope {
|
||||
sourceGroup: string;
|
||||
isMain: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RECENT_MINUTES = 60;
|
||||
const MAX_RECENT_MINUTES = 24 * 60;
|
||||
const DEFAULT_ROW_LIMIT = 20;
|
||||
const MAX_ROW_LIMIT = 100;
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9._:@/-]{1,200}$/;
|
||||
|
||||
export function normalizeDbEvidenceMinutes(value?: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_RECENT_MINUTES;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return Math.min(Math.max(normalized, 1), MAX_RECENT_MINUTES);
|
||||
}
|
||||
|
||||
export function normalizeDbEvidenceLimit(value?: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_ROW_LIMIT;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return Math.min(Math.max(normalized, 1), MAX_ROW_LIMIT);
|
||||
}
|
||||
|
||||
export function normalizeDbEvidenceTaskId(value?: string): string {
|
||||
const taskId = value?.trim();
|
||||
if (!taskId || !TASK_ID_PATTERN.test(taskId)) {
|
||||
throw new Error(`Unsupported paired task id for DB evidence: ${value}`);
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
function stringifyEvidence(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function groupScopeClause(scope: DbEvidenceScope): {
|
||||
clause: string;
|
||||
params: SqlBinding[];
|
||||
} {
|
||||
return scope.isMain
|
||||
? { clause: '', params: [] }
|
||||
: { clause: ' AND group_folder = ?', params: [scope.sourceGroup] };
|
||||
}
|
||||
|
||||
function getScopedTask(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
scope: DbEvidenceScope,
|
||||
): Record<string, unknown> | null {
|
||||
const groupScope = groupScopeClause(scope);
|
||||
const row = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
group_folder,
|
||||
chat_jid,
|
||||
status,
|
||||
round_trip_count,
|
||||
owner_failure_count,
|
||||
owner_step_done_streak,
|
||||
finalize_step_done_count,
|
||||
task_done_then_user_reopen_count,
|
||||
empty_step_done_streak,
|
||||
arbiter_verdict,
|
||||
arbiter_requested_at,
|
||||
completion_reason,
|
||||
owner_agent_type,
|
||||
reviewer_agent_type,
|
||||
arbiter_agent_type,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM paired_tasks
|
||||
WHERE id = ?
|
||||
${groupScope.clause}
|
||||
`,
|
||||
)
|
||||
.get(taskId, ...groupScope.params) as Record<string, unknown> | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
function runPairedTaskStatus(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const taskId = normalizeDbEvidenceTaskId(request.taskId);
|
||||
const task = getScopedTask(database, taskId, scope);
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
task,
|
||||
});
|
||||
}
|
||||
|
||||
function runPairedTaskFlow(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const taskId = normalizeDbEvidenceTaskId(request.taskId);
|
||||
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||
const task = getScopedTask(database, taskId, scope);
|
||||
if (!task) {
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
task: null,
|
||||
turns: [],
|
||||
attempts: [],
|
||||
outputs: [],
|
||||
deliveries: [],
|
||||
});
|
||||
}
|
||||
|
||||
const turns = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT turn_id,
|
||||
role,
|
||||
intent_kind,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM paired_turns
|
||||
WHERE task_id = ?
|
||||
ORDER BY updated_at, created_at, turn_id
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit);
|
||||
|
||||
const attempts = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT attempt_id,
|
||||
parent_attempt_id,
|
||||
turn_id,
|
||||
attempt_no,
|
||||
role,
|
||||
intent_kind,
|
||||
state,
|
||||
executor_agent_type,
|
||||
active_run_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
length(last_error) AS last_error_chars
|
||||
FROM paired_turn_attempts
|
||||
WHERE task_id = ?
|
||||
ORDER BY updated_at, attempt_no, attempt_id
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit);
|
||||
|
||||
const outputs = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT turn_number,
|
||||
role,
|
||||
verdict,
|
||||
created_at,
|
||||
length(output_text) AS output_chars
|
||||
FROM paired_turn_outputs
|
||||
WHERE task_id = ?
|
||||
ORDER BY turn_number, created_at, role
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(taskId, limit);
|
||||
|
||||
const deliveries = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
agent_type,
|
||||
delivery_role,
|
||||
status,
|
||||
delivery_attempts,
|
||||
created_at,
|
||||
updated_at,
|
||||
delivered_at,
|
||||
length(result_payload) AS result_payload_chars,
|
||||
length(last_error) AS last_error_chars
|
||||
FROM work_items
|
||||
WHERE chat_jid = ?
|
||||
AND created_at >= ?
|
||||
AND created_at <= datetime(?, '+1 minute')
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(
|
||||
String(task.chat_jid),
|
||||
String(task.created_at),
|
||||
String(task.updated_at),
|
||||
limit,
|
||||
);
|
||||
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
task,
|
||||
turns,
|
||||
attempts,
|
||||
outputs,
|
||||
deliveries,
|
||||
});
|
||||
}
|
||||
|
||||
function runRecentPairedFailures(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const minutes = normalizeDbEvidenceMinutes(request.minutes);
|
||||
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
const groupScope = groupScopeClause(scope);
|
||||
|
||||
const tasks = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
group_folder,
|
||||
chat_jid,
|
||||
status,
|
||||
owner_failure_count,
|
||||
arbiter_verdict,
|
||||
completion_reason,
|
||||
arbiter_requested_at,
|
||||
updated_at
|
||||
FROM paired_tasks
|
||||
WHERE updated_at >= ?
|
||||
AND (
|
||||
owner_failure_count > 0
|
||||
OR status = 'arbiter_requested'
|
||||
OR arbiter_verdict IS NOT NULL
|
||||
OR completion_reason IS NOT NULL
|
||||
)
|
||||
${groupScope.clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(cutoff, ...groupScope.params, limit);
|
||||
|
||||
const attempts = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT attempts.task_id,
|
||||
attempts.turn_id,
|
||||
attempts.attempt_no,
|
||||
attempts.role,
|
||||
attempts.intent_kind,
|
||||
attempts.state,
|
||||
attempts.executor_agent_type,
|
||||
attempts.active_run_id,
|
||||
attempts.updated_at,
|
||||
attempts.completed_at,
|
||||
length(attempts.last_error) AS last_error_chars
|
||||
FROM paired_turn_attempts attempts
|
||||
JOIN paired_tasks tasks
|
||||
ON tasks.id = attempts.task_id
|
||||
WHERE attempts.updated_at >= ?
|
||||
AND (
|
||||
attempts.state = 'failed'
|
||||
OR attempts.last_error IS NOT NULL
|
||||
)
|
||||
${scope.isMain ? '' : 'AND tasks.group_folder = ?'}
|
||||
ORDER BY attempts.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
|
||||
|
||||
const deliveryRetries = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
delivery_role,
|
||||
status,
|
||||
delivery_attempts,
|
||||
updated_at,
|
||||
length(last_error) AS last_error_chars
|
||||
FROM work_items
|
||||
WHERE updated_at >= ?
|
||||
AND status = 'delivery_retry'
|
||||
${scope.isMain ? '' : 'AND group_folder = ?'}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
|
||||
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
window_minutes: minutes,
|
||||
cutoff,
|
||||
tasks,
|
||||
attempts,
|
||||
delivery_retries: deliveryRetries,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeScheduledTaskEvidenceRow(
|
||||
row: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const prompt = typeof row.prompt === 'string' ? row.prompt : '';
|
||||
const metadata = parseGitHubCiMetadata(
|
||||
typeof row.ci_metadata === 'string' ? row.ci_metadata : null,
|
||||
);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
group_folder: row.group_folder,
|
||||
chat_jid: row.chat_jid,
|
||||
agent_type: row.agent_type,
|
||||
room_role: row.room_role,
|
||||
ci_provider: row.ci_provider,
|
||||
ci_repo: metadata?.repo ?? null,
|
||||
ci_run_id: metadata?.run_id ?? null,
|
||||
ci_poll_count: metadata?.poll_count ?? null,
|
||||
ci_consecutive_errors: metadata?.consecutive_errors ?? null,
|
||||
ci_last_checked_at: metadata?.last_checked_at ?? null,
|
||||
ci_target: extractWatchCiTarget(prompt),
|
||||
max_duration_ms: row.max_duration_ms,
|
||||
status_message_id: row.status_message_id,
|
||||
status_started_at: row.status_started_at,
|
||||
schedule_type: row.schedule_type,
|
||||
schedule_value: row.schedule_value,
|
||||
next_run: row.next_run,
|
||||
last_run: row.last_run,
|
||||
last_result_chars: row.last_result_chars,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
prompt_chars: row.prompt_chars,
|
||||
};
|
||||
}
|
||||
|
||||
function runRecentScheduledTasks(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const minutes = normalizeDbEvidenceMinutes(request.minutes);
|
||||
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
const groupScope = groupScopeClause(scope);
|
||||
|
||||
const rows = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT id,
|
||||
group_folder,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
room_role,
|
||||
ci_provider,
|
||||
ci_metadata,
|
||||
max_duration_ms,
|
||||
status_message_id,
|
||||
status_started_at,
|
||||
prompt,
|
||||
length(prompt) AS prompt_chars,
|
||||
schedule_type,
|
||||
schedule_value,
|
||||
next_run,
|
||||
last_run,
|
||||
length(last_result) AS last_result_chars,
|
||||
status,
|
||||
created_at
|
||||
FROM scheduled_tasks
|
||||
WHERE (
|
||||
created_at >= ?
|
||||
OR last_run >= ?
|
||||
OR next_run >= ?
|
||||
OR status_started_at >= ?
|
||||
OR status IN ('active', 'paused')
|
||||
)
|
||||
${groupScope.clause}
|
||||
ORDER BY COALESCE(last_run, status_started_at, next_run, created_at) DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(cutoff, cutoff, cutoff, cutoff, ...groupScope.params, limit) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
window_minutes: minutes,
|
||||
cutoff,
|
||||
tasks: rows.map(normalizeScheduledTaskEvidenceRow),
|
||||
});
|
||||
}
|
||||
|
||||
function runScheduledTaskRuns(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
const minutes = normalizeDbEvidenceMinutes(request.minutes);
|
||||
const limit = normalizeDbEvidenceLimit(request.limit);
|
||||
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
const taskId = request.taskId
|
||||
? normalizeDbEvidenceTaskId(request.taskId)
|
||||
: null;
|
||||
const params: SqlBinding[] = [cutoff];
|
||||
const clauses = ['logs.run_at >= ?'];
|
||||
|
||||
if (taskId) {
|
||||
clauses.push('logs.task_id = ?');
|
||||
params.push(taskId);
|
||||
}
|
||||
if (!scope.isMain) {
|
||||
clauses.push('tasks.group_folder = ?');
|
||||
params.push(scope.sourceGroup);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const rows = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT logs.task_id,
|
||||
tasks.group_folder,
|
||||
tasks.chat_jid,
|
||||
tasks.agent_type,
|
||||
tasks.room_role,
|
||||
tasks.ci_provider,
|
||||
logs.run_at,
|
||||
logs.duration_ms,
|
||||
logs.status,
|
||||
length(logs.result) AS result_chars,
|
||||
length(logs.error) AS error_chars
|
||||
FROM task_run_logs logs
|
||||
LEFT JOIN scheduled_tasks tasks
|
||||
ON tasks.id = logs.task_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY logs.run_at DESC, logs.id DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(...params);
|
||||
|
||||
return stringifyEvidence({
|
||||
action: request.action,
|
||||
window_minutes: minutes,
|
||||
cutoff,
|
||||
task_id: taskId,
|
||||
runs: rows,
|
||||
});
|
||||
}
|
||||
|
||||
export function runDbEvidenceRequest(
|
||||
database: Database,
|
||||
request: DbEvidenceRequest,
|
||||
scope: DbEvidenceScope,
|
||||
): string {
|
||||
switch (request.action) {
|
||||
case 'db_paired_task_status':
|
||||
return runPairedTaskStatus(database, request, scope);
|
||||
case 'db_paired_task_flow':
|
||||
return runPairedTaskFlow(database, request, scope);
|
||||
case 'db_recent_paired_failures':
|
||||
return runRecentPairedFailures(database, request, scope);
|
||||
case 'db_recent_scheduled_tasks':
|
||||
return runRecentScheduledTasks(database, request, scope);
|
||||
case 'db_scheduled_task_runs':
|
||||
return runScheduledTaskRuns(database, request, scope);
|
||||
}
|
||||
}
|
||||
258
src/db-paired-turn-attempt-recovery.test.ts
Normal file
258
src/db-paired-turn-attempt-recovery.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
createPairedTask,
|
||||
getPairedTurnAttempts,
|
||||
markPairedTurnRunning,
|
||||
recoverInterruptedPairedTurnAttemptsForService,
|
||||
} from './db.js';
|
||||
import { CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID } from './config.js';
|
||||
import { requireDatabase } from './db/runtime-database.js';
|
||||
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
||||
import type { PairedTask } from './types.js';
|
||||
|
||||
function makeTask(overrides: Partial<PairedTask> = {}): PairedTask {
|
||||
return {
|
||||
id: 'restart-running-attempt-task',
|
||||
chat_jid: 'dc:restart-room',
|
||||
group_folder: 'restart-room',
|
||||
owner_service_id: CODEX_MAIN_SERVICE_ID,
|
||||
reviewer_service_id: 'claude',
|
||||
owner_agent_type: 'codex',
|
||||
reviewer_agent_type: 'claude-code',
|
||||
arbiter_agent_type: null,
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
plan_notes: null,
|
||||
review_requested_at: null,
|
||||
round_trip_count: 1,
|
||||
owner_failure_count: 0,
|
||||
owner_step_done_streak: 0,
|
||||
finalize_step_done_count: 0,
|
||||
task_done_then_user_reopen_count: 0,
|
||||
empty_step_done_streak: 0,
|
||||
status: 'active',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-05-27T00:00:00.000Z',
|
||||
updated_at: '2026-05-27T00:10:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('paired turn attempt restart recovery', () => {
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
it('fails local-service running attempts so restart recovery can retry the turn', () => {
|
||||
const task = makeTask();
|
||||
createPairedTask(task);
|
||||
const turnIdentity = buildPairedTurnIdentity({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
intentKind: 'owner-turn',
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
markPairedTurnRunning({
|
||||
turnIdentity,
|
||||
executorServiceId: CODEX_MAIN_SERVICE_ID,
|
||||
executorAgentType: 'codex',
|
||||
runId: 'run-before-restart',
|
||||
});
|
||||
|
||||
const recovered = recoverInterruptedPairedTurnAttemptsForService({
|
||||
serviceIds: [CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID],
|
||||
now: '2026-05-27T00:11:00.000Z',
|
||||
});
|
||||
|
||||
expect(recovered).toEqual([
|
||||
expect.objectContaining({
|
||||
chat_jid: task.chat_jid,
|
||||
group_folder: task.group_folder,
|
||||
task_id: task.id,
|
||||
task_status: 'active',
|
||||
turn_id: turnIdentity.turnId,
|
||||
attempt_no: 1,
|
||||
role: 'owner',
|
||||
intent_kind: 'owner-turn',
|
||||
}),
|
||||
]);
|
||||
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
|
||||
expect.objectContaining({
|
||||
attempt_no: 1,
|
||||
state: 'failed',
|
||||
active_run_id: null,
|
||||
completed_at: '2026-05-27T00:11:00.000Z',
|
||||
last_error: 'Interrupted by service restart before completion.',
|
||||
}),
|
||||
]);
|
||||
|
||||
markPairedTurnRunning({
|
||||
turnIdentity,
|
||||
executorServiceId: CODEX_MAIN_SERVICE_ID,
|
||||
executorAgentType: 'codex',
|
||||
runId: 'run-after-restart',
|
||||
});
|
||||
|
||||
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
|
||||
expect.objectContaining({
|
||||
attempt_no: 1,
|
||||
state: 'failed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
attempt_no: 2,
|
||||
parent_attempt_id: `${turnIdentity.turnId}:attempt:1`,
|
||||
state: 'running',
|
||||
active_run_id: 'run-after-restart',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not touch running attempts owned by another service', () => {
|
||||
const task = makeTask({ id: 'other-service-task' });
|
||||
createPairedTask(task);
|
||||
const turnIdentity = buildPairedTurnIdentity({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
intentKind: 'reviewer-turn',
|
||||
role: 'reviewer',
|
||||
});
|
||||
|
||||
markPairedTurnRunning({
|
||||
turnIdentity,
|
||||
executorServiceId: 'claude',
|
||||
executorAgentType: 'claude-code',
|
||||
runId: 'reviewer-run',
|
||||
});
|
||||
|
||||
expect(
|
||||
recoverInterruptedPairedTurnAttemptsForService({
|
||||
serviceIds: [CODEX_MAIN_SERVICE_ID],
|
||||
now: '2026-05-27T00:11:00.000Z',
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'running',
|
||||
active_run_id: 'reviewer-run',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears stale running attempts for already completed tasks without replaying them', () => {
|
||||
const task = makeTask({
|
||||
id: 'completed-stale-running-task',
|
||||
status: 'completed',
|
||||
completion_reason: 'done',
|
||||
});
|
||||
createPairedTask(task);
|
||||
const turnIdentity = buildPairedTurnIdentity({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
intentKind: 'owner-turn',
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
markPairedTurnRunning({
|
||||
turnIdentity,
|
||||
executorServiceId: CODEX_MAIN_SERVICE_ID,
|
||||
executorAgentType: 'codex',
|
||||
runId: 'completed-run-before-restart',
|
||||
});
|
||||
|
||||
expect(
|
||||
recoverInterruptedPairedTurnAttemptsForService({
|
||||
serviceIds: [CODEX_MAIN_SERVICE_ID],
|
||||
now: '2026-05-27T00:11:00.000Z',
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'failed',
|
||||
active_run_id: null,
|
||||
completed_at: '2026-05-27T00:11:00.000Z',
|
||||
last_error: 'Interrupted by service restart before completion.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('recovers codex executor attempts even when the orchestration lease used the claude service id', () => {
|
||||
const task = makeTask({ id: 'cross-service-lease-task' });
|
||||
createPairedTask(task);
|
||||
const turnIdentity = buildPairedTurnIdentity({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
intentKind: 'owner-turn',
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
markPairedTurnRunning({
|
||||
turnIdentity,
|
||||
executorServiceId: CODEX_MAIN_SERVICE_ID,
|
||||
executorAgentType: 'codex',
|
||||
runId: 'codex-run-before-restart',
|
||||
});
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO paired_task_execution_leases (
|
||||
task_id,
|
||||
chat_jid,
|
||||
role,
|
||||
turn_id,
|
||||
turn_attempt_id,
|
||||
turn_attempt_no,
|
||||
intent_kind,
|
||||
claimed_run_id,
|
||||
claimed_service_id,
|
||||
task_status,
|
||||
task_updated_at,
|
||||
claimed_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
task.id,
|
||||
task.chat_jid,
|
||||
'owner',
|
||||
turnIdentity.turnId,
|
||||
`${turnIdentity.turnId}:attempt:1`,
|
||||
1,
|
||||
'owner-turn',
|
||||
'orchestrator-run-before-restart',
|
||||
CLAUDE_SERVICE_ID,
|
||||
task.status,
|
||||
task.updated_at,
|
||||
'2026-05-27T00:10:00.000Z',
|
||||
'2026-05-27T00:10:00.000Z',
|
||||
'2026-05-27T00:20:00.000Z',
|
||||
);
|
||||
|
||||
expect(
|
||||
recoverInterruptedPairedTurnAttemptsForService({
|
||||
serviceIds: [CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID],
|
||||
now: '2026-05-27T00:11:00.000Z',
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
task_id: task.id,
|
||||
turn_id: turnIdentity.turnId,
|
||||
role: 'owner',
|
||||
intent_kind: 'owner-turn',
|
||||
}),
|
||||
]);
|
||||
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'failed',
|
||||
active_run_id: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
155
src/db-paired-turn-attempts-bad-request.test.ts
Normal file
155
src/db-paired-turn-attempts-bad-request.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
createPairedTask,
|
||||
failPairedTurn,
|
||||
getOwnerCodexBadRequestFailureSummaryForTask,
|
||||
markPairedTurnRunning,
|
||||
} from './db.js';
|
||||
import { CODEX_MAIN_SERVICE_ID } from './config.js';
|
||||
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
||||
import type { PairedTask } from './types.js';
|
||||
|
||||
const BAD_REQUEST_ERROR = '{"detail":"Bad Request"}';
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
function makeTask(overrides: Partial<PairedTask> = {}): PairedTask {
|
||||
return {
|
||||
id: 'task-bad-request',
|
||||
chat_jid: 'dc:room',
|
||||
group_folder: 'eyejokerdb-9',
|
||||
owner_service_id: CODEX_MAIN_SERVICE_ID,
|
||||
reviewer_service_id: 'claude',
|
||||
title: null,
|
||||
source_ref: null,
|
||||
plan_notes: null,
|
||||
review_requested_at: null,
|
||||
round_trip_count: 1,
|
||||
status: 'active',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-04-29T08:00:00.000Z',
|
||||
updated_at: '2026-04-29T08:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function recordOwnerCodexFailure(args: {
|
||||
taskId: string;
|
||||
taskUpdatedAt: string;
|
||||
runId: string;
|
||||
error: string;
|
||||
}): void {
|
||||
const turnIdentity = buildPairedTurnIdentity({
|
||||
taskId: args.taskId,
|
||||
taskUpdatedAt: args.taskUpdatedAt,
|
||||
intentKind: 'owner-follow-up',
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
markPairedTurnRunning({
|
||||
turnIdentity,
|
||||
executorServiceId: CODEX_MAIN_SERVICE_ID,
|
||||
executorAgentType: 'codex',
|
||||
runId: args.runId,
|
||||
});
|
||||
failPairedTurn({ turnIdentity, error: args.error });
|
||||
}
|
||||
|
||||
describe('owner Codex Bad Request attempt summaries', () => {
|
||||
it('summarizes consecutive owner Codex Bad Request failures for a task', () => {
|
||||
const task = makeTask();
|
||||
createPairedTask(task);
|
||||
|
||||
for (const runId of ['run-1', 'run-2', 'run-3']) {
|
||||
recordOwnerCodexFailure({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
runId,
|
||||
error: BAD_REQUEST_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
getOwnerCodexBadRequestFailureSummaryForTask({
|
||||
taskId: task.id,
|
||||
threshold: 3,
|
||||
}),
|
||||
).toMatchObject({
|
||||
taskId: task.id,
|
||||
failures: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not summarize when the latest owner Codex failures are not all the narrow Bad Request signal', () => {
|
||||
const task = makeTask();
|
||||
createPairedTask(task);
|
||||
|
||||
recordOwnerCodexFailure({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
runId: 'run-1',
|
||||
error: BAD_REQUEST_ERROR,
|
||||
});
|
||||
recordOwnerCodexFailure({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
runId: 'run-2',
|
||||
error: BAD_REQUEST_ERROR,
|
||||
});
|
||||
recordOwnerCodexFailure({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
runId: 'run-3',
|
||||
error: 'HTTP 400 Bad Request',
|
||||
});
|
||||
|
||||
expect(
|
||||
getOwnerCodexBadRequestFailureSummaryForTask({
|
||||
taskId: task.id,
|
||||
threshold: 3,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('counts only the latest consecutive Bad Request failures', () => {
|
||||
const task = makeTask();
|
||||
createPairedTask(task);
|
||||
|
||||
recordOwnerCodexFailure({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
runId: 'run-1',
|
||||
error: BAD_REQUEST_ERROR,
|
||||
});
|
||||
recordOwnerCodexFailure({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
runId: 'run-2',
|
||||
error: 'HTTP 400 Bad Request',
|
||||
});
|
||||
for (const runId of ['run-3', 'run-4', 'run-5']) {
|
||||
recordOwnerCodexFailure({
|
||||
taskId: task.id,
|
||||
taskUpdatedAt: task.updated_at,
|
||||
runId,
|
||||
error: BAD_REQUEST_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
getOwnerCodexBadRequestFailureSummaryForTask({
|
||||
taskId: task.id,
|
||||
threshold: 3,
|
||||
}),
|
||||
).toMatchObject({
|
||||
taskId: task.id,
|
||||
failures: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/db-paired-turn-outputs.test.ts
Normal file
76
src/db-paired-turn-outputs.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyBaseSchema } from './db/base-schema.js';
|
||||
import {
|
||||
getPairedTurnOutputsFromDatabase,
|
||||
insertPairedTurnOutputInDatabase,
|
||||
} from './db/paired-turn-outputs.js';
|
||||
|
||||
describe('paired turn output attachments', () => {
|
||||
it('marks truncated output text so reviewers know evidence was clipped', () => {
|
||||
const database = new Database(':memory:');
|
||||
try {
|
||||
applyBaseSchema(database);
|
||||
|
||||
insertPairedTurnOutputInDatabase(
|
||||
database,
|
||||
'paired-task-turn-output-truncated',
|
||||
1,
|
||||
'owner',
|
||||
'x'.repeat(60_000),
|
||||
);
|
||||
|
||||
const [output] = getPairedTurnOutputsFromDatabase(
|
||||
database,
|
||||
'paired-task-turn-output-truncated',
|
||||
);
|
||||
|
||||
expect(output.output_text).toHaveLength(50_000);
|
||||
expect(output.output_text).toMatch(
|
||||
/\[Output truncated: 60000 > 50000 chars\]/,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves attachments for reviewer evidence prompts', () => {
|
||||
const database = new Database(':memory:');
|
||||
try {
|
||||
applyBaseSchema(database);
|
||||
|
||||
insertPairedTurnOutputInDatabase(
|
||||
database,
|
||||
'paired-task-turn-output-attachments',
|
||||
1,
|
||||
'owner',
|
||||
'TASK_DONE\n새 스크린샷 첨부',
|
||||
{
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/settings-v0.1.92-deployed-390.png',
|
||||
name: 'settings-v0.1.92-deployed-390.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const outputs = getPairedTurnOutputsFromDatabase(
|
||||
database,
|
||||
'paired-task-turn-output-attachments',
|
||||
);
|
||||
|
||||
expect(outputs[0].attachments).toEqual([
|
||||
{
|
||||
path: '/tmp/settings-v0.1.92-deployed-390.png',
|
||||
name: 'settings-v0.1.92-deployed-390.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
62
src/db-paired-turn-progress.test.ts
Normal file
62
src/db-paired-turn-progress.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
getLatestPairedTurnForTask,
|
||||
markPairedTurnRunning,
|
||||
reservePairedTurnReservation,
|
||||
updatePairedTurnProgressText,
|
||||
} from './db.js';
|
||||
import { CODEX_MAIN_SERVICE_ID } from './config.js';
|
||||
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
describe('paired turn progress activity', () => {
|
||||
it('selects the latest paired turn by progress activity timestamp', async () => {
|
||||
const taskId = 'progress-latest-task';
|
||||
const activeTurn = buildPairedTurnIdentity({
|
||||
taskId,
|
||||
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
|
||||
intentKind: 'owner-follow-up',
|
||||
role: 'owner',
|
||||
});
|
||||
const queuedTurn = buildPairedTurnIdentity({
|
||||
taskId,
|
||||
taskUpdatedAt: '2026-04-10T00:01:00.000Z',
|
||||
intentKind: 'owner-follow-up',
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
markPairedTurnRunning({
|
||||
turnIdentity: activeTurn,
|
||||
executorServiceId: CODEX_MAIN_SERVICE_ID,
|
||||
executorAgentType: 'codex',
|
||||
runId: 'run-progress-active',
|
||||
});
|
||||
expect(
|
||||
reservePairedTurnReservation({
|
||||
chatJid: 'dc:ops',
|
||||
taskId,
|
||||
taskStatus: 'active',
|
||||
roundTripCount: 1,
|
||||
taskUpdatedAt: queuedTurn.taskUpdatedAt,
|
||||
intentKind: queuedTurn.intentKind,
|
||||
runId: 'run-queued-empty',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
updatePairedTurnProgressText(
|
||||
activeTurn.turnId,
|
||||
'checking dashboard parity',
|
||||
);
|
||||
|
||||
expect(getLatestPairedTurnForTask(taskId)).toMatchObject({
|
||||
turn_id: activeTurn.turnId,
|
||||
progress_text: 'checking dashboard parity',
|
||||
});
|
||||
});
|
||||
});
|
||||
29
src/db-room-assignment.test.ts
Normal file
29
src/db-room-assignment.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { _initTestDatabase, assignRoom, getStoredRoomSettings } from './db.js';
|
||||
|
||||
describe('room assignment metadata', () => {
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
it('preserves explicit trigger metadata when assigning a room', () => {
|
||||
const group = assignRoom('dc:triggered-room', {
|
||||
name: 'Triggered Room',
|
||||
roomMode: 'single',
|
||||
ownerAgentType: 'codex',
|
||||
folder: 'triggered-room',
|
||||
trigger: '@Repro',
|
||||
requiresTrigger: true,
|
||||
});
|
||||
|
||||
expect(group).toMatchObject({
|
||||
trigger: '@Repro',
|
||||
requiresTrigger: true,
|
||||
});
|
||||
expect(getStoredRoomSettings('dc:triggered-room')).toMatchObject({
|
||||
trigger: '@Repro',
|
||||
requiresTrigger: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
120
src/db-scheduled-evidence.test.ts
Normal file
120
src/db-scheduled-evidence.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { _initTestDatabase } from './db.js';
|
||||
import { requireDatabase } from './db/runtime-database.js';
|
||||
import { runDbEvidenceRequest } from './db-evidence.js';
|
||||
|
||||
function seedScheduledTaskEvidence(): void {
|
||||
_initTestDatabase();
|
||||
const now = new Date().toISOString();
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO scheduled_tasks (
|
||||
id, group_folder, chat_jid, agent_type, room_role, ci_provider,
|
||||
ci_metadata, max_duration_ms, status_message_id, status_started_at,
|
||||
prompt, schedule_type, schedule_value, context_mode, next_run,
|
||||
last_run, last_result, status, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'watch-1',
|
||||
'room-folder',
|
||||
'room-1',
|
||||
'codex',
|
||||
'owner',
|
||||
'github',
|
||||
JSON.stringify({
|
||||
repo: 'phj1081/EJClaw',
|
||||
run_id: 123,
|
||||
poll_count: 2,
|
||||
consecutive_errors: 1,
|
||||
last_checked_at: now,
|
||||
}),
|
||||
300_000,
|
||||
'status-msg-1',
|
||||
now,
|
||||
'[BACKGROUND CI WATCH]\ntarget=PR #180 checks\nSECRET PROMPT BODY',
|
||||
'interval',
|
||||
'15000',
|
||||
'isolated',
|
||||
now,
|
||||
now,
|
||||
'raw CI result body',
|
||||
'active',
|
||||
now,
|
||||
);
|
||||
requireDatabase()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO task_run_logs (
|
||||
task_id, run_at, duration_ms, status, result, error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
'watch-1',
|
||||
now,
|
||||
42,
|
||||
'success',
|
||||
'raw scheduler result',
|
||||
'raw scheduler error',
|
||||
);
|
||||
}
|
||||
|
||||
describe('scheduled task DB evidence presets', () => {
|
||||
it('returns scheduled task metadata without raw prompt or result bodies', () => {
|
||||
seedScheduledTaskEvidence();
|
||||
|
||||
const tasksText = runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_recent_scheduled_tasks', minutes: 1440 },
|
||||
{ sourceGroup: 'room-folder', isMain: false },
|
||||
);
|
||||
|
||||
expect(tasksText).toContain('"ci_repo": "phj1081/EJClaw"');
|
||||
expect(tasksText).toContain('"ci_run_id": 123');
|
||||
expect(tasksText).toContain('"status_message_id": "status-msg-1"');
|
||||
expect(tasksText).toContain('"prompt_chars"');
|
||||
expect(tasksText).toContain('"last_result_chars"');
|
||||
expect(tasksText).not.toContain('SECRET PROMPT BODY');
|
||||
expect(tasksText).not.toContain('raw CI result body');
|
||||
});
|
||||
|
||||
it('returns scheduled run metadata without raw result or error bodies', () => {
|
||||
seedScheduledTaskEvidence();
|
||||
|
||||
const runsText = runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{
|
||||
action: 'db_scheduled_task_runs',
|
||||
taskId: 'watch-1',
|
||||
minutes: 1440,
|
||||
},
|
||||
{ sourceGroup: 'room-folder', isMain: false },
|
||||
);
|
||||
|
||||
expect(runsText).toContain('"duration_ms": 42');
|
||||
expect(runsText).toContain('"result_chars"');
|
||||
expect(runsText).toContain('"error_chars"');
|
||||
expect(runsText).not.toContain('raw scheduler result');
|
||||
expect(runsText).not.toContain('raw scheduler error');
|
||||
});
|
||||
|
||||
it('scopes scheduled task evidence for non-main rooms', () => {
|
||||
seedScheduledTaskEvidence();
|
||||
|
||||
const scopedOut = JSON.parse(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{ action: 'db_recent_scheduled_tasks', minutes: 1440 },
|
||||
{ sourceGroup: 'other-folder', isMain: false },
|
||||
),
|
||||
) as { tasks: unknown[] };
|
||||
|
||||
expect(scopedOut.tasks).toEqual([]);
|
||||
});
|
||||
});
|
||||
28
src/db-sessions.test.ts
Normal file
28
src/db-sessions.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
deleteAllSessionsForGroup,
|
||||
getSession,
|
||||
setSession,
|
||||
} from './db.js';
|
||||
|
||||
describe('session accessors', () => {
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
it('deletes owner, reviewer, and arbiter role-scoped sessions for a group', () => {
|
||||
setSession('group-a', 'owner-session');
|
||||
setSession('group-a:reviewer', 'reviewer-session');
|
||||
setSession('group-a:arbiter', 'arbiter-session');
|
||||
setSession('group-b', 'other-session');
|
||||
|
||||
deleteAllSessionsForGroup('group-a');
|
||||
|
||||
expect(getSession('group-a')).toBeUndefined();
|
||||
expect(getSession('group-a:reviewer')).toBeUndefined();
|
||||
expect(getSession('group-a:arbiter')).toBeUndefined();
|
||||
expect(getSession('group-b')).toBe('other-session');
|
||||
});
|
||||
});
|
||||
51
src/db-work-items.test.ts
Normal file
51
src/db-work-items.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
createProducedWorkItem,
|
||||
getOpenWorkItem,
|
||||
getRecentDeliveredWorkItemsForChat,
|
||||
markWorkItemDelivered,
|
||||
} from './db.js';
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
describe('work item canonical output queue', () => {
|
||||
it('supersedes stale open work items before creating a newer canonical output', () => {
|
||||
const stale = createProducedWorkItem({
|
||||
group_folder: 'discord_test',
|
||||
chat_jid: 'dc:supersede',
|
||||
agent_type: 'codex',
|
||||
service_id: 'codex-main',
|
||||
delivery_role: 'owner',
|
||||
start_seq: 1,
|
||||
end_seq: 2,
|
||||
result_payload: 'stale owner output',
|
||||
});
|
||||
const fresh = createProducedWorkItem({
|
||||
group_folder: 'discord_test',
|
||||
chat_jid: 'dc:supersede',
|
||||
agent_type: 'codex',
|
||||
service_id: 'codex-main',
|
||||
delivery_role: 'owner',
|
||||
start_seq: 3,
|
||||
end_seq: 4,
|
||||
result_payload: 'fresh owner output',
|
||||
});
|
||||
|
||||
expect(fresh.id).not.toBe(stale.id);
|
||||
expect(getOpenWorkItem('dc:supersede', 'codex', 'codex-main')?.id).toBe(
|
||||
fresh.id,
|
||||
);
|
||||
|
||||
markWorkItemDelivered(fresh.id, 'discord-message-id');
|
||||
expect(getRecentDeliveredWorkItemsForChat('dc:supersede', 10)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: fresh.id,
|
||||
result_payload: 'fresh owner output',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
getEffectiveRuntimeRoomMode,
|
||||
getExplicitRoomMode,
|
||||
getLatestMessageSeqAtOrBefore,
|
||||
getLatestPairedTaskForChat,
|
||||
getLatestTurnNumber,
|
||||
getLastRespondingAgentType,
|
||||
getRegisteredGroup,
|
||||
@@ -199,7 +198,7 @@ function getStoredRoleOverridesForLegacyMigration(
|
||||
agent_config_json: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}> = [];
|
||||
}>;
|
||||
try {
|
||||
rows = database
|
||||
.prepare(
|
||||
|
||||
19
src/db.ts
19
src/db.ts
@@ -11,7 +11,6 @@ export {
|
||||
enforceMemoryBounds,
|
||||
expireStaleMemories,
|
||||
getAllChats,
|
||||
getEarliestUnansweredHumanSeq,
|
||||
getLastHumanMessageContent,
|
||||
getLastHumanMessageSender,
|
||||
getLastHumanMessageTimestamp,
|
||||
@@ -22,8 +21,10 @@ export {
|
||||
getNewMessagesBySeq,
|
||||
getOpenWorkItem,
|
||||
getOpenWorkItemForChat,
|
||||
getRecentDeliveredWorkItemsForChat,
|
||||
getRecentChatMessages,
|
||||
getRecentDeliveredOwnerWorkItemsForChat,
|
||||
getRecentChatMessagesBatch,
|
||||
hasMessage,
|
||||
hasRecentRestartAnnouncement,
|
||||
markWorkItemDelivered,
|
||||
markWorkItemDeliveryRetry,
|
||||
@@ -42,6 +43,7 @@ export {
|
||||
createTask,
|
||||
deleteAllSessionsForGroup,
|
||||
deleteSession,
|
||||
deleteStoredRoomSkillOverride,
|
||||
deleteTask,
|
||||
findDuplicateCiWatcher,
|
||||
getAllRoomBindings,
|
||||
@@ -61,6 +63,7 @@ export {
|
||||
getSessionForAgentType,
|
||||
getStoredRoomRoleAgentPlan,
|
||||
getStoredRoomSettings,
|
||||
getStoredRoomSkillOverrides,
|
||||
getTaskById,
|
||||
getTasksForGroup,
|
||||
hasActiveCiWatcherForChat,
|
||||
@@ -70,6 +73,7 @@ export {
|
||||
setRouterStateForService,
|
||||
setSession,
|
||||
setSessionForAgentType,
|
||||
upsertStoredRoomSkillOverride,
|
||||
updateRegisteredGroupName,
|
||||
updateTask,
|
||||
updateTaskAfterRun,
|
||||
@@ -88,6 +92,7 @@ export {
|
||||
createServiceHandoff,
|
||||
failPairedTurn,
|
||||
failServiceHandoff,
|
||||
getAllOpenPairedTasks,
|
||||
getAllChannelOwnerLeases,
|
||||
getAllPendingServiceHandoffs,
|
||||
getChannelOwnerLease,
|
||||
@@ -96,18 +101,23 @@ export {
|
||||
getLatestPairedTaskForChat,
|
||||
getLatestPreviousPairedTaskForChat,
|
||||
getLatestTurnNumber,
|
||||
getOwnerCodexBadRequestFailureSummaryForTask,
|
||||
getPairedProject,
|
||||
getPairedTaskById,
|
||||
getPairedTurnAttempts,
|
||||
getPairedTurnById,
|
||||
getPairedTurnOutputs,
|
||||
getRecentPairedTurnOutputsForChat,
|
||||
getPairedTurnsForTask,
|
||||
getLatestPairedTurnForTask,
|
||||
updatePairedTurnProgressText,
|
||||
getPairedWorkspace,
|
||||
getPendingServiceHandoffs,
|
||||
insertPairedTurnOutput,
|
||||
listPairedWorkspacesForTask,
|
||||
markPairedTurnRunning,
|
||||
refreshPairedTaskExecutionLease,
|
||||
recoverInterruptedPairedTurnAttemptsForService,
|
||||
releasePairedTaskExecutionLease,
|
||||
reservePairedTurnReservation,
|
||||
setChannelOwnerLease,
|
||||
@@ -124,7 +134,10 @@ export type {
|
||||
MemorySourceKind,
|
||||
RecallMemoryQuery,
|
||||
} from './db/memories.js';
|
||||
export type { PairedTurnAttemptRecord } from './db/paired-turn-attempts.js';
|
||||
export type {
|
||||
InterruptedPairedTurnAttemptRecoveryCandidate,
|
||||
PairedTurnAttemptRecord,
|
||||
} from './db/paired-turn-attempts.js';
|
||||
export type { PairedTurnRecord } from './db/paired-turns.js';
|
||||
export type { ChatInfo } from './db/messages.js';
|
||||
export type { WorkItem } from './db/work-items.js';
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
const ROOM_SKILL_OVERRIDES_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS room_skill_overrides (
|
||||
chat_jid TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
skill_scope TEXT NOT NULL,
|
||||
skill_name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name),
|
||||
FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE,
|
||||
CHECK (agent_type IN ('claude-code', 'codex')),
|
||||
CHECK (enabled IN (0, 1)),
|
||||
CHECK (length(skill_scope) > 0),
|
||||
CHECK (length(skill_name) > 0)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room
|
||||
ON room_skill_overrides(chat_jid, agent_type);
|
||||
`;
|
||||
|
||||
export function applyBaseSchema(database: Database): void {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
@@ -24,6 +44,7 @@ export function applyBaseSchema(database: Database): void {
|
||||
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_timestamp ON messages(chat_jid, timestamp DESC);
|
||||
CREATE TABLE IF NOT EXISTS message_sequence (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
);
|
||||
@@ -160,7 +181,7 @@ export function applyBaseSchema(database: Database): void {
|
||||
task_id TEXT NOT NULL,
|
||||
turn_number INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
output_text TEXT NOT NULL,
|
||||
output_text TEXT NOT NULL, attachment_payload TEXT,
|
||||
verdict TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(task_id, turn_number, role)
|
||||
@@ -410,8 +431,7 @@ export function applyBaseSchema(database: Database): void {
|
||||
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_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(
|
||||
@@ -435,4 +455,5 @@ export function applyBaseSchema(database: Database): void {
|
||||
VALUES (new.id, new.content, new.keywords_json);
|
||||
END;
|
||||
`);
|
||||
database.exec(ROOM_SKILL_OVERRIDES_SCHEMA);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ function getExpectedSchemaMigrations(): Array<{
|
||||
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
|
||||
{ version: 13, name: 'message_source_kind' },
|
||||
{ version: 14, name: 'work_item_attachments' },
|
||||
{ version: 15, name: 'reviewer_failure_count' },
|
||||
{ version: 15, name: 'turn_progress_text' },
|
||||
{ version: 16, name: 'room_skill_overrides' },
|
||||
{ version: 17, name: 'scheduled_task_room_role' },
|
||||
{ version: 18, name: 'paired_turn_output_attachments' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
inferRoleFromServiceShadow,
|
||||
resolveRoleServiceShadow,
|
||||
} from '../role-service-shadow.js';
|
||||
import type { AgentType, PairedRoomRole } from '../types.js';
|
||||
import {
|
||||
normalizePairedRoomRoleOrNull,
|
||||
type AgentType,
|
||||
type PairedRoomRole,
|
||||
} from '../types.js';
|
||||
import { normalizeStoredAgentType } from './room-registration.js';
|
||||
|
||||
interface RequiredRoleMetadataInput {
|
||||
@@ -20,7 +24,7 @@ interface RequiredRoleMetadataInput {
|
||||
fallbackAgentType?: AgentType | null;
|
||||
}
|
||||
|
||||
interface OptionalRoleMetadataInput extends RequiredRoleMetadataInput {}
|
||||
type OptionalRoleMetadataInput = RequiredRoleMetadataInput;
|
||||
|
||||
function resolveFilledRequiredAgentType(
|
||||
input: RequiredRoleMetadataInput,
|
||||
@@ -336,14 +340,6 @@ export function readCanonicalChannelOwnerLeaseMetadata(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHandoffRole(
|
||||
role: string | null | undefined,
|
||||
): PairedRoomRole | null {
|
||||
return role === 'owner' || role === 'reviewer' || role === 'arbiter'
|
||||
? role
|
||||
: null;
|
||||
}
|
||||
|
||||
function assertStoredRoleMatchesServiceShadow(args: {
|
||||
context: string;
|
||||
storedRole: PairedRoomRole | null;
|
||||
@@ -389,11 +385,11 @@ export function fillCanonicalServiceHandoffMetadata(input: {
|
||||
}): CanonicalServiceHandoffMetadata {
|
||||
const context = `service_handoffs(${input.id})`;
|
||||
const sourceRole =
|
||||
normalizeHandoffRole(input.source_role) ??
|
||||
normalizeHandoffRole(input.intended_role);
|
||||
normalizePairedRoomRoleOrNull(input.source_role) ??
|
||||
normalizePairedRoomRoleOrNull(input.intended_role);
|
||||
const targetRole =
|
||||
normalizeHandoffRole(input.target_role) ??
|
||||
normalizeHandoffRole(input.intended_role);
|
||||
normalizePairedRoomRoleOrNull(input.target_role) ??
|
||||
normalizePairedRoomRoleOrNull(input.intended_role);
|
||||
const storedSourceAgentType = normalizeStoredAgentType(
|
||||
input.source_agent_type,
|
||||
);
|
||||
@@ -541,11 +537,11 @@ export function readCanonicalServiceHandoffMetadata(input: {
|
||||
}): CanonicalServiceHandoffMetadata {
|
||||
const context = `service_handoffs(${input.id})`;
|
||||
const sourceRole =
|
||||
normalizeHandoffRole(input.source_role) ??
|
||||
normalizeHandoffRole(input.intended_role);
|
||||
normalizePairedRoomRoleOrNull(input.source_role) ??
|
||||
normalizePairedRoomRoleOrNull(input.intended_role);
|
||||
const targetRole =
|
||||
normalizeHandoffRole(input.target_role) ??
|
||||
normalizeHandoffRole(input.intended_role);
|
||||
normalizePairedRoomRoleOrNull(input.target_role) ??
|
||||
normalizePairedRoomRoleOrNull(input.intended_role);
|
||||
const sourceServiceId = readStoredHandoffServiceId({
|
||||
context,
|
||||
field: 'source_service_id',
|
||||
|
||||
@@ -95,6 +95,17 @@ export function getAllChatsFromDatabase(database: Database): ChatInfo[] {
|
||||
.all() as ChatInfo[];
|
||||
}
|
||||
|
||||
export function hasMessageInDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
id: string,
|
||||
): boolean {
|
||||
const row = database
|
||||
.prepare('SELECT 1 FROM messages WHERE chat_jid = ? AND id = ? LIMIT 1')
|
||||
.get(chatJid, id);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function storeMessageInDatabase(
|
||||
database: Database,
|
||||
msg: NewMessage,
|
||||
@@ -308,22 +319,31 @@ export function getMessagesSinceSeqFromDatabase(
|
||||
return rows.map(normalizeMessageRow);
|
||||
}
|
||||
|
||||
const recentChatMessagesStmtCache = new WeakMap<
|
||||
Database,
|
||||
ReturnType<Database['prepare']>
|
||||
>();
|
||||
|
||||
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, message_source_kind
|
||||
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<
|
||||
let stmt = recentChatMessagesStmtCache.get(database);
|
||||
if (!stmt) {
|
||||
stmt = database.prepare(`
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind
|
||||
FROM messages
|
||||
WHERE chat_jid = ?
|
||||
AND content != '' AND content IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
) ORDER BY timestamp
|
||||
`);
|
||||
recentChatMessagesStmtCache.set(database, stmt);
|
||||
}
|
||||
const rows = stmt.all(chatJid, limit) as Array<
|
||||
NewMessage & {
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
@@ -332,36 +352,42 @@ export function getRecentChatMessagesFromDatabase(
|
||||
return rows.map(normalizeMessageRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the seq of the earliest unanswered human message in this chat —
|
||||
* i.e. the first user message that came after the most recent bot reply.
|
||||
* Returns null if no such message exists (the user is "caught up").
|
||||
*
|
||||
* Used by restart-recovery to rewind the agent cursor before re-running an
|
||||
* interrupted turn from the message that initiated it.
|
||||
*/
|
||||
export function getEarliestUnansweredHumanSeqFromDatabase(
|
||||
export function getRecentChatMessagesBatchFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): number | null {
|
||||
const lastBot = database
|
||||
.prepare(
|
||||
`SELECT MAX(seq) AS seq FROM messages
|
||||
WHERE chat_jid = ? AND is_bot_message = 1`,
|
||||
chatJids: string[],
|
||||
limit: number = 8,
|
||||
): Map<string, NewMessage[]> {
|
||||
const out = new Map<string, NewMessage[]>();
|
||||
if (chatJids.length === 0) return out;
|
||||
const placeholders = chatJids.map(() => '?').join(',');
|
||||
const sql = `
|
||||
WITH ranked AS (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp,
|
||||
is_from_me, is_bot_message, message_source_kind,
|
||||
ROW_NUMBER() OVER (PARTITION BY chat_jid ORDER BY timestamp DESC) AS rn
|
||||
FROM messages
|
||||
WHERE chat_jid IN (${placeholders})
|
||||
AND content != '' AND content IS NOT NULL
|
||||
)
|
||||
.get(chatJid) as { seq: number | null } | undefined;
|
||||
const lastBotSeq = lastBot?.seq ?? 0;
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT MIN(seq) AS seq FROM messages
|
||||
WHERE chat_jid = ?
|
||||
AND seq > ?
|
||||
AND is_bot_message = 0
|
||||
AND is_from_me = 0
|
||||
AND content != '' AND content IS NOT NULL`,
|
||||
)
|
||||
.get(chatJid, lastBotSeq) as { seq: number | null } | undefined;
|
||||
return row?.seq ?? null;
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp,
|
||||
is_from_me, is_bot_message, message_source_kind
|
||||
FROM ranked
|
||||
WHERE rn <= ?
|
||||
ORDER BY chat_jid, timestamp ASC
|
||||
`;
|
||||
const rows = database.prepare(sql).all(...chatJids, limit) as Array<
|
||||
NewMessage & {
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
}
|
||||
>;
|
||||
for (const row of rows) {
|
||||
const normalized = normalizeMessageRow(row);
|
||||
const existing = out.get(normalized.chat_jid);
|
||||
if (existing) existing.push(normalized);
|
||||
else out.set(normalized.chat_jid, [normalized]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getLastHumanMessageTimestampFromDatabase(
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
import { tableHasColumn } from './helpers.js';
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
|
||||
export const REVIEWER_FAILURE_COUNT_MIGRATION: SchemaMigrationDefinition = {
|
||||
version: 15,
|
||||
name: 'reviewer_failure_count',
|
||||
apply(database: Database) {
|
||||
if (!tableHasColumn(database, 'paired_tasks', 'reviewer_failure_count')) {
|
||||
database.exec(`
|
||||
ALTER TABLE paired_tasks
|
||||
ADD COLUMN reviewer_failure_count INTEGER NOT NULL DEFAULT 0
|
||||
`);
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
UPDATE paired_tasks
|
||||
SET reviewer_failure_count = 0
|
||||
WHERE reviewer_failure_count IS NULL
|
||||
`);
|
||||
},
|
||||
};
|
||||
19
src/db/migrations/015_turn-progress-text.ts
Normal file
19
src/db/migrations/015_turn-progress-text.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
import { tableHasColumn } from './helpers.js';
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
|
||||
export const TURN_PROGRESS_TEXT_MIGRATION: SchemaMigrationDefinition = {
|
||||
version: 15,
|
||||
name: 'turn_progress_text',
|
||||
apply(database: Database) {
|
||||
if (!tableHasColumn(database, 'paired_turns', 'progress_text')) {
|
||||
database.exec(`ALTER TABLE paired_turns ADD COLUMN progress_text TEXT`);
|
||||
}
|
||||
if (!tableHasColumn(database, 'paired_turns', 'progress_updated_at')) {
|
||||
database.exec(
|
||||
`ALTER TABLE paired_turns ADD COLUMN progress_updated_at TEXT`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
29
src/db/migrations/016_room-skill-overrides.ts
Normal file
29
src/db/migrations/016_room-skill-overrides.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
|
||||
export const ROOM_SKILL_OVERRIDES_MIGRATION: SchemaMigrationDefinition = {
|
||||
version: 16,
|
||||
name: 'room_skill_overrides',
|
||||
apply(database: Database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS room_skill_overrides (
|
||||
chat_jid TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
skill_scope TEXT NOT NULL,
|
||||
skill_name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name),
|
||||
FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE,
|
||||
CHECK (agent_type IN ('claude-code', 'codex')),
|
||||
CHECK (enabled IN (0, 1)),
|
||||
CHECK (length(skill_scope) > 0),
|
||||
CHECK (length(skill_name) > 0)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room
|
||||
ON room_skill_overrides(chat_jid, agent_type);
|
||||
`);
|
||||
},
|
||||
};
|
||||
13
src/db/migrations/017_scheduled-task-room-role.ts
Normal file
13
src/db/migrations/017_scheduled-task-room-role.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
import { tryExecMigration } from './helpers.js';
|
||||
|
||||
export const SCHEDULED_TASK_ROOM_ROLE_MIGRATION = {
|
||||
version: 17,
|
||||
name: 'scheduled_task_room_role',
|
||||
apply(database) {
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN room_role TEXT`,
|
||||
);
|
||||
},
|
||||
} satisfies SchemaMigrationDefinition;
|
||||
20
src/db/migrations/018_paired-turn-output-attachments.ts
Normal file
20
src/db/migrations/018_paired-turn-output-attachments.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
import { tableHasColumn } from './helpers.js';
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
|
||||
export const PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition =
|
||||
{
|
||||
version: 18,
|
||||
name: 'paired_turn_output_attachments',
|
||||
apply(database: Database) {
|
||||
if (
|
||||
!tableHasColumn(database, 'paired_turn_outputs', 'attachment_payload')
|
||||
) {
|
||||
database.exec(`
|
||||
ALTER TABLE paired_turn_outputs
|
||||
ADD COLUMN attachment_payload TEXT
|
||||
`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -14,7 +14,10 @@ import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
|
||||
import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
|
||||
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
|
||||
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
|
||||
import { REVIEWER_FAILURE_COUNT_MIGRATION } from './015_reviewer-failure-count.js';
|
||||
import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
|
||||
import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js';
|
||||
import { SCHEDULED_TASK_ROOM_ROLE_MIGRATION } from './017_scheduled-task-room-role.js';
|
||||
import { PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION } from './018_paired-turn-output-attachments.js';
|
||||
import type {
|
||||
SchemaMigrationArgs,
|
||||
SchemaMigrationDefinition,
|
||||
@@ -37,7 +40,10 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
||||
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
|
||||
MESSAGE_SOURCE_KIND_MIGRATION,
|
||||
WORK_ITEM_ATTACHMENTS_MIGRATION,
|
||||
REVIEWER_FAILURE_COUNT_MIGRATION,
|
||||
TURN_PROGRESS_TEXT_MIGRATION,
|
||||
ROOM_SKILL_OVERRIDES_MIGRATION,
|
||||
SCHEDULED_TASK_ROOM_ROLE_MIGRATION,
|
||||
PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION,
|
||||
];
|
||||
|
||||
function ensureSchemaMigrationsTable(database: Database): void {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
AgentType,
|
||||
PairedProject,
|
||||
PairedRoomRole,
|
||||
PairedTask,
|
||||
PairedTaskStatus,
|
||||
PairedTurnReservationIntentKind,
|
||||
@@ -51,7 +50,6 @@ export type PairedTaskUpdates = Partial<
|
||||
| 'review_requested_at'
|
||||
| 'round_trip_count'
|
||||
| 'owner_failure_count'
|
||||
| 'reviewer_failure_count'
|
||||
| 'owner_step_done_streak'
|
||||
| 'finalize_step_done_count'
|
||||
| 'task_done_then_user_reopen_count'
|
||||
@@ -178,7 +176,6 @@ export function createPairedTaskInDatabase(
|
||||
review_requested_at,
|
||||
round_trip_count,
|
||||
owner_failure_count,
|
||||
reviewer_failure_count,
|
||||
owner_step_done_streak,
|
||||
finalize_step_done_count,
|
||||
task_done_then_user_reopen_count,
|
||||
@@ -190,7 +187,7 @@ export function createPairedTaskInDatabase(
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
@@ -208,7 +205,6 @@ export function createPairedTaskInDatabase(
|
||||
task.review_requested_at,
|
||||
task.round_trip_count,
|
||||
task.owner_failure_count ?? 0,
|
||||
task.reviewer_failure_count ?? 0,
|
||||
task.owner_step_done_streak ?? 0,
|
||||
task.finalize_step_done_count ?? 0,
|
||||
task.task_done_then_user_reopen_count ?? 0,
|
||||
@@ -232,21 +228,27 @@ export function getPairedTaskByIdFromDatabase(
|
||||
return row ? hydratePairedTaskRow(database, row) : undefined;
|
||||
}
|
||||
|
||||
const latestPairedTaskStmtCache = new WeakMap<
|
||||
Database,
|
||||
ReturnType<Database['prepare']>
|
||||
>();
|
||||
|
||||
export function getLatestPairedTaskForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): PairedTask | undefined {
|
||||
const row = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_tasks
|
||||
WHERE chat_jid = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(chatJid) as StoredPairedTaskRow | undefined;
|
||||
let stmt = latestPairedTaskStmtCache.get(database);
|
||||
if (!stmt) {
|
||||
stmt = database.prepare(`
|
||||
SELECT *
|
||||
FROM paired_tasks
|
||||
WHERE chat_jid = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
latestPairedTaskStmtCache.set(database, stmt);
|
||||
}
|
||||
const row = stmt.get(chatJid) as StoredPairedTaskRow | undefined;
|
||||
return row ? hydratePairedTaskRow(database, row) : undefined;
|
||||
}
|
||||
|
||||
@@ -269,6 +271,22 @@ export function getLatestOpenPairedTaskForChatFromDatabase(
|
||||
return row ? hydratePairedTaskRow(database, row) : undefined;
|
||||
}
|
||||
|
||||
export function getAllOpenPairedTasksFromDatabase(
|
||||
database: Database,
|
||||
): PairedTask[] {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT *
|
||||
FROM paired_tasks
|
||||
WHERE status NOT IN ('completed')
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
`,
|
||||
)
|
||||
.all() as StoredPairedTaskRow[];
|
||||
return rows.map((row) => hydratePairedTaskRow(database, row));
|
||||
}
|
||||
|
||||
export function getLatestPreviousPairedTaskForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
@@ -321,10 +339,6 @@ export function updatePairedTaskInDatabase(
|
||||
fields.push('owner_failure_count = ?');
|
||||
values.push(updates.owner_failure_count);
|
||||
}
|
||||
if (updates.reviewer_failure_count !== undefined) {
|
||||
fields.push('reviewer_failure_count = ?');
|
||||
values.push(updates.reviewer_failure_count);
|
||||
}
|
||||
if (updates.owner_step_done_streak !== undefined) {
|
||||
fields.push('owner_step_done_streak = ?');
|
||||
values.push(updates.owner_step_done_streak);
|
||||
@@ -404,10 +418,6 @@ export function updatePairedTaskIfUnchangedInDatabase(
|
||||
fields.push('owner_failure_count = ?');
|
||||
values.push(updates.owner_failure_count);
|
||||
}
|
||||
if (updates.reviewer_failure_count !== undefined) {
|
||||
fields.push('reviewer_failure_count = ?');
|
||||
values.push(updates.reviewer_failure_count);
|
||||
}
|
||||
if (updates.owner_step_done_streak !== undefined) {
|
||||
fields.push('owner_step_done_streak = ?');
|
||||
values.push(updates.owner_step_done_streak);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { normalizeServiceId } from '../config.js';
|
||||
import { CODEX_BAD_REQUEST_DETAIL_JSON } from '../codex-bad-request-signal.js';
|
||||
import type { PairedTurnIdentity } from '../paired-turn-identity.js';
|
||||
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
|
||||
import type { AgentType, PairedRoomRole } from '../types.js';
|
||||
@@ -33,6 +34,25 @@ export interface PairedTurnAttemptRecord {
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface InterruptedPairedTurnAttemptRecoveryCandidate {
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
task_id: string;
|
||||
task_status: string;
|
||||
turn_id: string;
|
||||
attempt_id: string;
|
||||
attempt_no: number;
|
||||
role: PairedRoomRole;
|
||||
intent_kind: PairedTurnIdentity['intentKind'];
|
||||
}
|
||||
|
||||
export interface OwnerCodexBadRequestFailureSummary {
|
||||
taskId: string;
|
||||
failures: number;
|
||||
firstFailureAt: string;
|
||||
latestFailureAt: string;
|
||||
}
|
||||
|
||||
function resolveExecutorMetadata(args: {
|
||||
executorServiceId?: string | null;
|
||||
executorAgentType?: AgentType | null;
|
||||
@@ -423,6 +443,154 @@ export function getPairedTurnAttemptIdFromDatabase(
|
||||
return row?.attempt_id ?? null;
|
||||
}
|
||||
|
||||
export function recoverInterruptedPairedTurnAttemptsForServiceInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
serviceIds: string[];
|
||||
now?: string;
|
||||
error?: string;
|
||||
},
|
||||
): InterruptedPairedTurnAttemptRecoveryCandidate[] {
|
||||
const serviceIds = Array.from(
|
||||
new Set(args.serviceIds.map((serviceId) => normalizeServiceId(serviceId))),
|
||||
).filter((serviceId) => serviceId.length > 0);
|
||||
if (serviceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const servicePlaceholders = serviceIds.map(() => '?').join(', ');
|
||||
const now = args.now ?? new Date().toISOString();
|
||||
const error =
|
||||
args.error ?? 'Interrupted by service restart before completion.';
|
||||
return database.transaction(() => {
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE paired_turn_attempts
|
||||
SET state = 'failed',
|
||||
active_run_id = NULL,
|
||||
updated_at = ?,
|
||||
completed_at = ?,
|
||||
last_error = COALESCE(last_error, ?)
|
||||
WHERE state = 'running'
|
||||
AND executor_service_id IN (${servicePlaceholders})
|
||||
AND task_id IN (
|
||||
SELECT id FROM paired_tasks WHERE status = 'completed'
|
||||
)
|
||||
`,
|
||||
)
|
||||
.run(now, now, error, ...serviceIds);
|
||||
|
||||
const rows = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
tasks.chat_jid,
|
||||
tasks.group_folder,
|
||||
attempts.task_id,
|
||||
tasks.status AS task_status,
|
||||
attempts.turn_id,
|
||||
attempts.attempt_id,
|
||||
attempts.attempt_no,
|
||||
attempts.role,
|
||||
attempts.intent_kind
|
||||
FROM paired_turn_attempts attempts
|
||||
JOIN paired_tasks tasks
|
||||
ON tasks.id = attempts.task_id
|
||||
WHERE attempts.state = 'running'
|
||||
AND attempts.executor_service_id IN (${servicePlaceholders})
|
||||
AND tasks.status != 'completed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paired_task_execution_leases leases
|
||||
WHERE leases.task_id = attempts.task_id
|
||||
AND leases.turn_id = attempts.turn_id
|
||||
AND leases.claimed_service_id NOT IN (${servicePlaceholders})
|
||||
)
|
||||
ORDER BY attempts.updated_at ASC, attempts.attempt_no ASC
|
||||
`,
|
||||
)
|
||||
.all(
|
||||
...serviceIds,
|
||||
...serviceIds,
|
||||
) as InterruptedPairedTurnAttemptRecoveryCandidate[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const update = database.prepare(
|
||||
`
|
||||
UPDATE paired_turn_attempts
|
||||
SET state = 'failed',
|
||||
active_run_id = NULL,
|
||||
updated_at = ?,
|
||||
completed_at = ?,
|
||||
last_error = ?
|
||||
WHERE attempt_id = ?
|
||||
AND state = 'running'
|
||||
AND executor_service_id IN (${servicePlaceholders})
|
||||
`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
update.run(now, now, error, row.attempt_id, ...serviceIds);
|
||||
}
|
||||
return rows;
|
||||
})();
|
||||
}
|
||||
|
||||
export function getOwnerCodexBadRequestFailureSummaryForTaskFromDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
taskId: string;
|
||||
threshold: number;
|
||||
},
|
||||
): OwnerCodexBadRequestFailureSummary | null {
|
||||
const threshold = Math.max(1, Math.floor(args.threshold));
|
||||
const attempts = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT state, last_error, created_at
|
||||
FROM paired_turn_attempts
|
||||
WHERE task_id = ?
|
||||
AND role = 'owner'
|
||||
AND executor_agent_type = 'codex'
|
||||
ORDER BY created_at DESC, attempt_no DESC
|
||||
`,
|
||||
)
|
||||
.all(args.taskId) as Array<{
|
||||
state: PairedTurnAttemptState;
|
||||
last_error: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
const consecutiveFailures = [];
|
||||
for (const attempt of attempts) {
|
||||
if (
|
||||
attempt.state === 'failed' &&
|
||||
attempt.last_error?.trim() === CODEX_BAD_REQUEST_DETAIL_JSON
|
||||
) {
|
||||
consecutiveFailures.push(attempt);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (consecutiveFailures.length < threshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstFailureAt =
|
||||
consecutiveFailures[consecutiveFailures.length - 1].created_at;
|
||||
const latestFailureAt = consecutiveFailures[0].created_at;
|
||||
|
||||
return {
|
||||
taskId: args.taskId,
|
||||
failures: consecutiveFailures.length,
|
||||
firstFailureAt,
|
||||
latestFailureAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function setPairedTurnAttemptContinuationHandoffIdInDatabase(
|
||||
database: Database,
|
||||
args: {
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import { parseVisibleVerdict } from '../paired-verdict.js';
|
||||
import {
|
||||
parseReviewerVisibleVerdict,
|
||||
parseVisibleVerdict,
|
||||
} from '../paired-verdict.js';
|
||||
import { PairedRoomRole, PairedTurnOutput } from '../types.js';
|
||||
OutboundAttachment,
|
||||
PairedRoomRole,
|
||||
PairedTurnOutput,
|
||||
} from '../types.js';
|
||||
import {
|
||||
parseAttachmentPayload,
|
||||
serializeAttachmentPayload,
|
||||
} from './work-items.js';
|
||||
|
||||
const MAX_TURN_OUTPUT_CHARS = 50_000;
|
||||
|
||||
function storedOutputText(outputText: string): string {
|
||||
if (outputText.length <= MAX_TURN_OUTPUT_CHARS) {
|
||||
return outputText;
|
||||
}
|
||||
|
||||
const notice = `\n\n[Output truncated: ${outputText.length} > ${MAX_TURN_OUTPUT_CHARS} chars]`;
|
||||
return `${outputText.slice(0, MAX_TURN_OUTPUT_CHARS - notice.length)}${notice}`;
|
||||
}
|
||||
|
||||
export function insertPairedTurnOutputInDatabase(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
turnNumber: number,
|
||||
role: PairedRoomRole,
|
||||
outputText: string,
|
||||
createdAt?: string,
|
||||
options: {
|
||||
createdAt?: string;
|
||||
attachments?: OutboundAttachment[];
|
||||
} = {},
|
||||
): void {
|
||||
if (outputText.length > MAX_TURN_OUTPUT_CHARS) {
|
||||
logger.warn(
|
||||
@@ -33,21 +50,36 @@ export function insertPairedTurnOutputInDatabase(
|
||||
database
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO paired_turn_outputs
|
||||
(task_id, turn_number, role, output_text, verdict, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
(task_id, turn_number, role, output_text, attachment_payload, verdict, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskId,
|
||||
turnNumber,
|
||||
role,
|
||||
outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
|
||||
role === 'reviewer'
|
||||
? parseReviewerVisibleVerdict(outputText)
|
||||
: parseVisibleVerdict(outputText),
|
||||
createdAt ?? new Date().toISOString(),
|
||||
storedOutputText(outputText),
|
||||
serializeAttachmentPayload(options.attachments),
|
||||
parseVisibleVerdict(outputText),
|
||||
options.createdAt ?? new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
type StoredPairedTurnOutputRow = PairedTurnOutput & {
|
||||
attachment_payload?: string | null;
|
||||
};
|
||||
|
||||
function hydratePairedTurnOutputRow(
|
||||
row: StoredPairedTurnOutputRow,
|
||||
): PairedTurnOutput {
|
||||
return {
|
||||
...row,
|
||||
attachments: parseAttachmentPayload(row.attachment_payload, {
|
||||
table: 'paired_turn_outputs',
|
||||
rowId: row.id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPairedTurnOutputsFromDatabase(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
@@ -58,7 +90,34 @@ export function getPairedTurnOutputsFromDatabase(
|
||||
WHERE task_id = ?
|
||||
ORDER BY turn_number ASC`,
|
||||
)
|
||||
.all(taskId) as PairedTurnOutput[];
|
||||
.all(taskId)
|
||||
.map((row) => hydratePairedTurnOutputRow(row as StoredPairedTurnOutputRow));
|
||||
}
|
||||
|
||||
const recentOutputsForChatStmtCache = new WeakMap<
|
||||
Database,
|
||||
ReturnType<Database['prepare']>
|
||||
>();
|
||||
|
||||
export function getRecentPairedTurnOutputsForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
limit: number = 8,
|
||||
): PairedTurnOutput[] {
|
||||
let stmt = recentOutputsForChatStmtCache.get(database);
|
||||
if (!stmt) {
|
||||
stmt = database.prepare(`
|
||||
SELECT o.*
|
||||
FROM paired_turn_outputs o
|
||||
INNER JOIN paired_tasks t ON o.task_id = t.id
|
||||
WHERE t.chat_jid = ?
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
recentOutputsForChatStmtCache.set(database, stmt);
|
||||
}
|
||||
const rows = stmt.all(chatJid, limit) as StoredPairedTurnOutputRow[];
|
||||
return rows.reverse().map(hydratePairedTurnOutputRow);
|
||||
}
|
||||
|
||||
export function getLatestTurnNumberFromDatabase(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { buildPairedTurnAttemptId } from './paired-turn-attempts.js';
|
||||
import { tableHasColumn, tryExecMigration } from './migrations/helpers.js';
|
||||
import { tableHasColumn } from './migrations/helpers.js';
|
||||
|
||||
// Paired-turn provenance rebuild helpers extracted from the legacy schema
|
||||
// bundle. These remain runtime helpers because v10 replays them during
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface PairedTurnRecord {
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
last_error: string | null;
|
||||
progress_text?: string | null;
|
||||
progress_updated_at?: string | null;
|
||||
}
|
||||
|
||||
interface StoredPairedTurnRow {
|
||||
@@ -43,6 +45,8 @@ interface StoredPairedTurnRow {
|
||||
intent_kind: PairedTurnIdentity['intentKind'];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
progress_text?: string | null;
|
||||
progress_updated_at?: string | null;
|
||||
}
|
||||
|
||||
function hydratePairedTurnRecord(
|
||||
@@ -493,6 +497,67 @@ export function getPairedTurnByIdFromDatabase(
|
||||
);
|
||||
}
|
||||
|
||||
const updateProgressTextStmtCache = new WeakMap<
|
||||
Database,
|
||||
ReturnType<Database['prepare']>
|
||||
>();
|
||||
|
||||
export function updatePairedTurnProgressTextFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
progressText: string | null,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
let stmt = updateProgressTextStmtCache.get(database);
|
||||
if (!stmt) {
|
||||
stmt = database.prepare(`
|
||||
UPDATE paired_turns
|
||||
SET progress_text = ?,
|
||||
progress_updated_at = ?,
|
||||
updated_at = ?
|
||||
WHERE turn_id = ?
|
||||
`);
|
||||
updateProgressTextStmtCache.set(database, stmt);
|
||||
}
|
||||
stmt.run(progressText, now, now, turnId);
|
||||
}
|
||||
|
||||
const latestPairedTurnStmtCache = new WeakMap<
|
||||
Database,
|
||||
ReturnType<Database['prepare']>
|
||||
>();
|
||||
|
||||
export function getLatestPairedTurnForTaskFromDatabase(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
): PairedTurnRecord | null {
|
||||
let stmt = latestPairedTurnStmtCache.get(database);
|
||||
if (!stmt) {
|
||||
stmt = database.prepare(`
|
||||
SELECT *
|
||||
FROM paired_turns
|
||||
WHERE task_id = ?
|
||||
ORDER BY CASE
|
||||
WHEN progress_text IS NOT NULL
|
||||
AND trim(progress_text) <> ''
|
||||
AND progress_updated_at IS NOT NULL
|
||||
AND progress_updated_at > updated_at
|
||||
THEN progress_updated_at
|
||||
ELSE updated_at
|
||||
END DESC,
|
||||
turn_id DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
latestPairedTurnStmtCache.set(database, stmt);
|
||||
}
|
||||
const row = stmt.get(taskId) as StoredPairedTurnRow | undefined;
|
||||
if (!row) return null;
|
||||
return hydratePairedTurnRecord(
|
||||
row,
|
||||
getCurrentPairedTurnAttemptForTurnFromDatabase(database, row.turn_id),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPairedTurnsForTaskFromDatabase(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
|
||||
@@ -115,7 +115,7 @@ function getStoredRoomRoleOverrideRows(
|
||||
agent_config_json: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}> = [];
|
||||
}>;
|
||||
try {
|
||||
rows = database
|
||||
.prepare(
|
||||
|
||||
131
src/db/rooms.ts
131
src/db/rooms.ts
@@ -28,6 +28,24 @@ interface StoredRoomModeRow {
|
||||
source: RoomModeSource;
|
||||
}
|
||||
|
||||
export interface StoredRoomSkillOverride {
|
||||
chatJid: string;
|
||||
agentType: AgentType;
|
||||
skillScope: string;
|
||||
skillName: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StoredRoomSkillOverrideInput {
|
||||
chatJid: string;
|
||||
agentType: AgentType;
|
||||
skillScope: string;
|
||||
skillName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AssignRoomInput {
|
||||
name: string;
|
||||
roomMode?: RoomMode;
|
||||
@@ -35,6 +53,8 @@ export interface AssignRoomInput {
|
||||
reviewerAgentType?: AgentType;
|
||||
arbiterAgentType?: AgentType | null;
|
||||
folder?: string;
|
||||
trigger?: string;
|
||||
requiresTrigger?: boolean;
|
||||
isMain?: boolean;
|
||||
workDir?: string;
|
||||
addedAt?: string;
|
||||
@@ -186,8 +206,9 @@ export function assignRoomInDatabase(
|
||||
const snapshot: RoomRegistrationSnapshot = {
|
||||
name: input.name,
|
||||
folder,
|
||||
triggerPattern: existing?.trigger ?? '',
|
||||
requiresTrigger: existing?.requiresTrigger ?? false,
|
||||
triggerPattern: input.trigger ?? existing?.trigger ?? '',
|
||||
requiresTrigger:
|
||||
input.requiresTrigger ?? existing?.requiresTrigger ?? false,
|
||||
isMain: input.isMain ?? existing?.isMain ?? false,
|
||||
ownerAgentType,
|
||||
workDir: input.workDir ?? existing?.workDir ?? null,
|
||||
@@ -307,6 +328,112 @@ export function getStoredRoomRoleAgentPlanFromDatabase(
|
||||
return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined;
|
||||
}
|
||||
|
||||
export function getStoredRoomSkillOverridesFromDatabase(
|
||||
database: Database,
|
||||
chatJid?: string,
|
||||
): StoredRoomSkillOverride[] {
|
||||
const params: string[] = [];
|
||||
const where = chatJid ? 'WHERE chat_jid = ?' : '';
|
||||
if (chatJid) params.push(chatJid);
|
||||
|
||||
let rows: Array<{
|
||||
chat_jid: string;
|
||||
agent_type: string;
|
||||
skill_scope: string;
|
||||
skill_name: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
try {
|
||||
rows = database
|
||||
.prepare(
|
||||
`SELECT chat_jid, agent_type, skill_scope, skill_name, enabled,
|
||||
created_at, updated_at
|
||||
FROM room_skill_overrides
|
||||
${where}
|
||||
ORDER BY chat_jid, agent_type, skill_scope, skill_name`,
|
||||
)
|
||||
.all(...params) as Array<{
|
||||
chat_jid: string;
|
||||
agent_type: string;
|
||||
skill_scope: string;
|
||||
skill_name: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
const agentType = normalizeStoredAgentType(row.agent_type);
|
||||
if (!agentType) return null;
|
||||
return {
|
||||
chatJid: row.chat_jid,
|
||||
agentType,
|
||||
skillScope: row.skill_scope,
|
||||
skillName: row.skill_name,
|
||||
enabled: row.enabled === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
})
|
||||
.filter((row): row is StoredRoomSkillOverride => Boolean(row));
|
||||
}
|
||||
|
||||
export function upsertStoredRoomSkillOverrideInDatabase(
|
||||
database: Database,
|
||||
input: StoredRoomSkillOverrideInput,
|
||||
): void {
|
||||
const agentType = normalizeStoredAgentType(input.agentType);
|
||||
if (!agentType) {
|
||||
throw new Error(`Unsupported agent type: ${input.agentType}`);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO room_skill_overrides (
|
||||
chat_jid, agent_type, skill_scope, skill_name, enabled,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chat_jid, agent_type, skill_scope, skill_name)
|
||||
DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
input.chatJid,
|
||||
agentType,
|
||||
input.skillScope,
|
||||
input.skillName,
|
||||
input.enabled ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteStoredRoomSkillOverrideFromDatabase(
|
||||
database: Database,
|
||||
input: Omit<StoredRoomSkillOverrideInput, 'enabled'>,
|
||||
): void {
|
||||
const agentType = normalizeStoredAgentType(input.agentType);
|
||||
if (!agentType) {
|
||||
throw new Error(`Unsupported agent type: ${input.agentType}`);
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`DELETE FROM room_skill_overrides
|
||||
WHERE chat_jid = ?
|
||||
AND agent_type = ?
|
||||
AND skill_scope = ?
|
||||
AND skill_name = ?`,
|
||||
)
|
||||
.run(input.chatJid, agentType, input.skillScope, input.skillName);
|
||||
}
|
||||
|
||||
function getStoredRoomModeRowFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import {
|
||||
type ChatInfo,
|
||||
getAllChatsFromDatabase,
|
||||
getEarliestUnansweredHumanSeqFromDatabase,
|
||||
getLastHumanMessageContentFromDatabase,
|
||||
getLastHumanMessageSenderFromDatabase,
|
||||
getLastHumanMessageTimestampFromDatabase,
|
||||
@@ -27,6 +26,8 @@ import {
|
||||
getNewMessagesBySeqFromDatabase,
|
||||
getNewMessagesFromDatabase,
|
||||
getRecentChatMessagesFromDatabase,
|
||||
getRecentChatMessagesBatchFromDatabase,
|
||||
hasMessageInDatabase,
|
||||
hasRecentRestartAnnouncementInDatabase,
|
||||
storeChatMetadataInDatabase,
|
||||
storeMessageInDatabase,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
createProducedWorkItemInDatabase,
|
||||
getOpenWorkItemForChatFromDatabase,
|
||||
getOpenWorkItemFromDatabase,
|
||||
getRecentDeliveredOwnerWorkItemsForChatFromDatabase,
|
||||
getRecentDeliveredWorkItemsForChatFromDatabase,
|
||||
markWorkItemDeliveredInDatabase,
|
||||
markWorkItemDeliveryRetryInDatabase,
|
||||
} from './work-items.js';
|
||||
@@ -143,6 +144,10 @@ export function storeMessage(msg: NewMessage): void {
|
||||
storeMessageInDatabase(requireDatabase(), msg);
|
||||
}
|
||||
|
||||
export function hasMessage(chatJid: string, id: string): boolean {
|
||||
return hasMessageInDatabase(requireDatabase(), chatJid, id);
|
||||
}
|
||||
|
||||
export function getNewMessages(
|
||||
jids: string[],
|
||||
lastTimestamp: string,
|
||||
@@ -221,12 +226,19 @@ export function getRecentChatMessages(
|
||||
return getRecentChatMessagesFromDatabase(requireDatabase(), chatJid, limit);
|
||||
}
|
||||
|
||||
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
||||
return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid);
|
||||
export function getRecentChatMessagesBatch(
|
||||
chatJids: string[],
|
||||
limit: number = 8,
|
||||
): Map<string, NewMessage[]> {
|
||||
return getRecentChatMessagesBatchFromDatabase(
|
||||
requireDatabase(),
|
||||
chatJids,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
export function getEarliestUnansweredHumanSeq(chatJid: string): number | null {
|
||||
return getEarliestUnansweredHumanSeqFromDatabase(requireDatabase(), chatJid);
|
||||
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
||||
return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid);
|
||||
}
|
||||
|
||||
export function getLastHumanMessageSender(chatJid: string): string | null {
|
||||
@@ -272,11 +284,11 @@ export function getOpenWorkItemForChat(
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecentDeliveredOwnerWorkItemsForChat(
|
||||
export function getRecentDeliveredWorkItemsForChat(
|
||||
chatJid: string,
|
||||
limit: number,
|
||||
limit: number = 8,
|
||||
): WorkItem[] {
|
||||
return getRecentDeliveredOwnerWorkItemsForChatFromDatabase(
|
||||
return getRecentDeliveredWorkItemsForChatFromDatabase(
|
||||
requireDatabase(),
|
||||
chatJid,
|
||||
limit,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
clearPairedTurnReservationsInDatabase,
|
||||
type PairedTaskUpdates,
|
||||
createPairedTaskInDatabase,
|
||||
getAllOpenPairedTasksFromDatabase,
|
||||
getLastBotFinalMessageFromDatabase,
|
||||
getLatestOpenPairedTaskForChatFromDatabase,
|
||||
getLatestPreviousPairedTaskForChatFromDatabase,
|
||||
@@ -42,12 +43,17 @@ import {
|
||||
} from './paired-state.js';
|
||||
import {
|
||||
clearPairedTurnAttemptsInDatabase,
|
||||
getOwnerCodexBadRequestFailureSummaryForTaskFromDatabase,
|
||||
getPairedTurnAttemptsForTurnFromDatabase,
|
||||
recoverInterruptedPairedTurnAttemptsForServiceInDatabase,
|
||||
type InterruptedPairedTurnAttemptRecoveryCandidate,
|
||||
type OwnerCodexBadRequestFailureSummary,
|
||||
type PairedTurnAttemptRecord,
|
||||
} from './paired-turn-attempts.js';
|
||||
import {
|
||||
getLatestTurnNumberFromDatabase,
|
||||
getPairedTurnOutputsFromDatabase,
|
||||
getRecentPairedTurnOutputsForChatFromDatabase,
|
||||
insertPairedTurnOutputInDatabase,
|
||||
} from './paired-turn-outputs.js';
|
||||
import {
|
||||
@@ -57,6 +63,8 @@ import {
|
||||
failPairedTurnInDatabase,
|
||||
getPairedTurnByIdFromDatabase,
|
||||
getPairedTurnsForTaskFromDatabase,
|
||||
getLatestPairedTurnForTaskFromDatabase,
|
||||
updatePairedTurnProgressTextFromDatabase,
|
||||
markPairedTurnRunningInDatabase,
|
||||
type PairedTurnRecord,
|
||||
} from './paired-turns.js';
|
||||
@@ -113,6 +121,10 @@ export function getLatestPreviousPairedTaskForChat(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllOpenPairedTasks(): PairedTask[] {
|
||||
return getAllOpenPairedTasksFromDatabase(requireDatabase());
|
||||
}
|
||||
|
||||
export function updatePairedTask(id: string, updates: PairedTaskUpdates): void {
|
||||
updatePairedTaskInDatabase(requireDatabase(), id, updates);
|
||||
}
|
||||
@@ -206,12 +218,50 @@ export function getPairedTurnsForTask(taskId: string): PairedTurnRecord[] {
|
||||
return getPairedTurnsForTaskFromDatabase(requireDatabase(), taskId);
|
||||
}
|
||||
|
||||
export function getLatestPairedTurnForTask(
|
||||
taskId: string,
|
||||
): PairedTurnRecord | null {
|
||||
return getLatestPairedTurnForTaskFromDatabase(requireDatabase(), taskId);
|
||||
}
|
||||
|
||||
export function updatePairedTurnProgressText(
|
||||
turnId: string,
|
||||
progressText: string | null,
|
||||
): void {
|
||||
updatePairedTurnProgressTextFromDatabase(
|
||||
requireDatabase(),
|
||||
turnId,
|
||||
progressText,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPairedTurnAttempts(
|
||||
turnId: string,
|
||||
): PairedTurnAttemptRecord[] {
|
||||
return getPairedTurnAttemptsForTurnFromDatabase(requireDatabase(), turnId);
|
||||
}
|
||||
|
||||
export function recoverInterruptedPairedTurnAttemptsForService(args: {
|
||||
serviceIds: string[];
|
||||
now?: string;
|
||||
error?: string;
|
||||
}): InterruptedPairedTurnAttemptRecoveryCandidate[] {
|
||||
return recoverInterruptedPairedTurnAttemptsForServiceInDatabase(
|
||||
requireDatabase(),
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
export function getOwnerCodexBadRequestFailureSummaryForTask(args: {
|
||||
taskId: string;
|
||||
threshold: number;
|
||||
}): OwnerCodexBadRequestFailureSummary | null {
|
||||
return getOwnerCodexBadRequestFailureSummaryForTaskFromDatabase(
|
||||
requireDatabase(),
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
||||
upsertPairedWorkspaceInDatabase(requireDatabase(), workspace);
|
||||
}
|
||||
@@ -312,15 +362,24 @@ export function insertPairedTurnOutput(
|
||||
turnNumber: number,
|
||||
role: PairedRoomRole,
|
||||
outputText: string,
|
||||
createdAt?: string,
|
||||
createdAtOrOptions?:
|
||||
| string
|
||||
| {
|
||||
createdAt?: string;
|
||||
attachments?: import('../types.js').OutboundAttachment[];
|
||||
},
|
||||
): void {
|
||||
const options =
|
||||
typeof createdAtOrOptions === 'string'
|
||||
? { createdAt: createdAtOrOptions }
|
||||
: (createdAtOrOptions ?? {});
|
||||
insertPairedTurnOutputInDatabase(
|
||||
requireDatabase(),
|
||||
taskId,
|
||||
turnNumber,
|
||||
role,
|
||||
outputText,
|
||||
createdAt,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,6 +387,17 @@ export function getPairedTurnOutputs(taskId: string): PairedTurnOutput[] {
|
||||
return getPairedTurnOutputsFromDatabase(requireDatabase(), taskId);
|
||||
}
|
||||
|
||||
export function getRecentPairedTurnOutputsForChat(
|
||||
chatJid: string,
|
||||
limit: number = 8,
|
||||
): PairedTurnOutput[] {
|
||||
return getRecentPairedTurnOutputsForChatFromDatabase(
|
||||
requireDatabase(),
|
||||
chatJid,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLatestTurnNumber(taskId: string): number {
|
||||
return getLatestTurnNumberFromDatabase(requireDatabase(), taskId);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
assignRoomInDatabase,
|
||||
clearExplicitRoomModeInDatabase,
|
||||
deleteStoredRoomSettingsForTestsInDatabase,
|
||||
deleteStoredRoomSkillOverrideFromDatabase,
|
||||
getAllRoomBindingsFromDatabase,
|
||||
getEffectiveRoomModeFromDatabase,
|
||||
getEffectiveRuntimeRoomModeFromDatabase,
|
||||
@@ -36,10 +37,14 @@ import {
|
||||
getRegisteredGroupFromDatabase,
|
||||
getStoredRoomRoleAgentPlanFromDatabase,
|
||||
getStoredRoomSettingsFromDatabase,
|
||||
getStoredRoomSkillOverridesFromDatabase,
|
||||
setExplicitRoomModeInDatabase,
|
||||
setRegisteredGroupForTestsInDatabase,
|
||||
setStoredRoomOwnerAgentTypeForTestsInDatabase,
|
||||
type StoredRoomSkillOverride,
|
||||
type StoredRoomSkillOverrideInput,
|
||||
updateRegisteredGroupNameInDatabase,
|
||||
upsertStoredRoomSkillOverrideInDatabase,
|
||||
} from './rooms.js';
|
||||
import { type StoredRoomSettings } from './room-registration.js';
|
||||
import {
|
||||
@@ -363,6 +368,26 @@ export function getStoredRoomRoleAgentPlan(
|
||||
return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid);
|
||||
}
|
||||
|
||||
export function getStoredRoomSkillOverrides(
|
||||
chatJid?: string,
|
||||
): StoredRoomSkillOverride[] {
|
||||
const db = getDatabaseIfInitialized();
|
||||
if (!db) return [];
|
||||
return getStoredRoomSkillOverridesFromDatabase(db, chatJid);
|
||||
}
|
||||
|
||||
export function upsertStoredRoomSkillOverride(
|
||||
input: StoredRoomSkillOverrideInput,
|
||||
): void {
|
||||
upsertStoredRoomSkillOverrideInDatabase(requireDatabase(), input);
|
||||
}
|
||||
|
||||
export function deleteStoredRoomSkillOverride(
|
||||
input: Omit<StoredRoomSkillOverrideInput, 'enabled'>,
|
||||
): void {
|
||||
deleteStoredRoomSkillOverrideFromDatabase(requireDatabase(), input);
|
||||
}
|
||||
|
||||
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
|
||||
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ function parseLastAgentSeqState(
|
||||
`Invalid last_agent_seq JSON for ${serviceId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,8 +141,11 @@ export function deleteAllSessionsForGroupFromDatabase(
|
||||
groupFolder: string,
|
||||
): void {
|
||||
database
|
||||
.prepare('DELETE FROM sessions WHERE group_folder = ?')
|
||||
.run(groupFolder);
|
||||
.prepare(
|
||||
`DELETE FROM sessions
|
||||
WHERE group_folder IN (?, ?, ?)`,
|
||||
)
|
||||
.run(groupFolder, `${groupFolder}:reviewer`, `${groupFolder}:arbiter`);
|
||||
}
|
||||
|
||||
export function getAllSessionsForAgentTypeFromDatabase(
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
DEFAULT_TASK_CONTEXT_MODE,
|
||||
WATCH_CI_PROMPT_PREFIX,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
import { AgentType, ScheduledTask, TaskRunLog } from '../types.js';
|
||||
import {
|
||||
AgentType,
|
||||
PairedRoomRole,
|
||||
ScheduledTask,
|
||||
TaskRunLog,
|
||||
} from '../types.js';
|
||||
|
||||
export type CreateScheduledTaskInput = Omit<
|
||||
ScheduledTask,
|
||||
| 'last_run'
|
||||
| 'last_result'
|
||||
| 'agent_type'
|
||||
| 'room_role'
|
||||
| 'ci_provider'
|
||||
| 'ci_metadata'
|
||||
| 'max_duration_ms'
|
||||
@@ -14,6 +24,7 @@ export type CreateScheduledTaskInput = Omit<
|
||||
| 'status_started_at'
|
||||
> & {
|
||||
agent_type?: AgentType | null;
|
||||
room_role?: PairedRoomRole | null;
|
||||
ci_provider?: ScheduledTask['ci_provider'];
|
||||
ci_metadata?: string | null;
|
||||
max_duration_ms?: number | null;
|
||||
@@ -45,8 +56,8 @@ export function createTaskInDatabase(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, room_role, 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(
|
||||
@@ -54,6 +65,7 @@ export function createTaskInDatabase(
|
||||
task.group_folder,
|
||||
task.chat_jid,
|
||||
task.agent_type || 'claude-code',
|
||||
task.room_role ?? null,
|
||||
task.ci_provider ?? null,
|
||||
task.ci_metadata ?? null,
|
||||
task.max_duration_ms ?? null,
|
||||
@@ -62,7 +74,7 @@ export function createTaskInDatabase(
|
||||
task.prompt,
|
||||
task.schedule_type,
|
||||
task.schedule_value,
|
||||
task.context_mode || 'isolated',
|
||||
task.context_mode || DEFAULT_TASK_CONTEXT_MODE,
|
||||
task.next_run,
|
||||
task.status,
|
||||
task.created_at,
|
||||
@@ -73,9 +85,10 @@ export function getTaskByIdFromDatabase(
|
||||
database: Database,
|
||||
id: string,
|
||||
): ScheduledTask | undefined {
|
||||
return database
|
||||
const row = database
|
||||
.prepare('SELECT * FROM scheduled_tasks WHERE id = ?')
|
||||
.get(id) as ScheduledTask | undefined;
|
||||
.get(id) as ScheduledTask | null | undefined;
|
||||
return row ?? undefined;
|
||||
}
|
||||
|
||||
export function findDuplicateCiWatcherInDatabase(
|
||||
@@ -208,10 +221,10 @@ export function hasActiveCiWatcherForChatInDatabase(
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT 1 FROM scheduled_tasks
|
||||
WHERE chat_jid = ? AND status = 'active' AND prompt LIKE '[BACKGROUND CI WATCH]%'
|
||||
WHERE chat_jid = ? AND status = 'active' AND prompt LIKE ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(chatJid);
|
||||
.get(chatJid, `${WATCH_CI_PROMPT_PREFIX}%`);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
inferAgentTypeFromServiceShadow,
|
||||
inferRoleFromServiceShadow,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from '../role-service-shadow.js';
|
||||
import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
|
||||
|
||||
const SUPERSEDED_WORK_ITEM_ERROR = 'superseded_by_newer_canonical_output';
|
||||
|
||||
export interface WorkItem {
|
||||
id: number;
|
||||
group_folder: string;
|
||||
@@ -99,36 +102,107 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem {
|
||||
...row,
|
||||
agent_type: agentType,
|
||||
service_id: readStoredWorkItemServiceId(row),
|
||||
attachments: parseAttachmentPayload(row.attachment_payload),
|
||||
attachments: parseAttachmentPayload(row.attachment_payload, {
|
||||
table: 'work_items',
|
||||
rowId: row.id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttachmentPayload(
|
||||
export interface AttachmentPayloadContext {
|
||||
table?: string;
|
||||
rowId?: string | number;
|
||||
}
|
||||
|
||||
function payloadPreview(payload: string): string {
|
||||
return payload.length > 200 ? `${payload.slice(0, 200)}...` : payload;
|
||||
}
|
||||
|
||||
function isAttachmentPayloadEntry(
|
||||
item: unknown,
|
||||
): item is { path: string; name?: unknown; mime?: unknown } {
|
||||
return (
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
!Array.isArray(item) &&
|
||||
typeof (item as { path?: unknown }).path === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function logMalformedAttachmentPayload(args: {
|
||||
payload: string;
|
||||
context?: AttachmentPayloadContext;
|
||||
reason: string;
|
||||
err?: unknown;
|
||||
}): void {
|
||||
logger.warn(
|
||||
{
|
||||
...args.context,
|
||||
reason: args.reason,
|
||||
payloadLength: args.payload.length,
|
||||
payloadPreview: payloadPreview(args.payload),
|
||||
...(args.err ? { err: args.err } : {}),
|
||||
},
|
||||
'Ignored malformed attachment payload',
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAttachmentPayload(
|
||||
payload: string | null | undefined,
|
||||
context?: AttachmentPayloadContext,
|
||||
): OutboundAttachment[] {
|
||||
if (!payload) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter(
|
||||
(item): item is OutboundAttachment =>
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
!Array.isArray(item) &&
|
||||
typeof (item as { path?: unknown }).path === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
path: item.path,
|
||||
...(typeof item.name === 'string' ? { name: item.name } : {}),
|
||||
...(typeof item.mime === 'string' ? { mime: item.mime } : {}),
|
||||
}));
|
||||
} catch {
|
||||
if (!Array.isArray(parsed)) {
|
||||
logMalformedAttachmentPayload({
|
||||
payload,
|
||||
context,
|
||||
reason: 'not_array',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments: OutboundAttachment[] = [];
|
||||
let invalidEntryCount = 0;
|
||||
for (const item of parsed) {
|
||||
if (isAttachmentPayloadEntry(item)) {
|
||||
attachments.push({
|
||||
path: item.path,
|
||||
...(typeof item.name === 'string' ? { name: item.name } : {}),
|
||||
...(typeof item.mime === 'string' ? { mime: item.mime } : {}),
|
||||
});
|
||||
} else {
|
||||
invalidEntryCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidEntryCount > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
...context,
|
||||
invalidEntryCount,
|
||||
validEntryCount: attachments.length,
|
||||
payloadLength: payload.length,
|
||||
payloadPreview: payloadPreview(payload),
|
||||
},
|
||||
'Ignored invalid attachment payload entries',
|
||||
);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
} catch (err) {
|
||||
logMalformedAttachmentPayload({
|
||||
payload,
|
||||
context,
|
||||
reason: 'invalid_json',
|
||||
err,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAttachmentPayload(
|
||||
export function serializeAttachmentPayload(
|
||||
attachments: OutboundAttachment[] | undefined,
|
||||
): string | null {
|
||||
if (!attachments?.length) return null;
|
||||
@@ -267,6 +341,57 @@ export function getOpenWorkItemForChatFromDatabase(
|
||||
return row ? hydrateWorkItemRow(row) : undefined;
|
||||
}
|
||||
|
||||
export function getRecentDeliveredWorkItemsForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
limit: number = 8,
|
||||
): WorkItem[] {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM work_items
|
||||
WHERE chat_jid = ?
|
||||
AND status = 'delivered'
|
||||
AND (last_error IS NULL OR last_error <> ?)
|
||||
ORDER BY COALESCE(delivered_at, updated_at, created_at) DESC, id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(chatJid, SUPERSEDED_WORK_ITEM_ERROR, limit) as StoredWorkItemRow[];
|
||||
return rows.reverse().map(hydrateWorkItemRow);
|
||||
}
|
||||
|
||||
function supersedeOpenWorkItemsForCanonicalKey(
|
||||
database: Database,
|
||||
input: CreateProducedWorkItemInput,
|
||||
agentType: AgentType,
|
||||
serviceId: string,
|
||||
now: string,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE work_items
|
||||
SET status = 'delivered',
|
||||
delivered_at = ?,
|
||||
delivery_message_id = NULL,
|
||||
last_error = ?,
|
||||
updated_at = ?
|
||||
WHERE chat_jid = ?
|
||||
AND agent_type = ?
|
||||
AND IFNULL(service_id, '') = IFNULL(?, '')
|
||||
AND IFNULL(delivery_role, '') = IFNULL(?, '')
|
||||
AND status IN ('produced', 'delivery_retry')`,
|
||||
)
|
||||
.run(
|
||||
now,
|
||||
SUPERSEDED_WORK_ITEM_ERROR,
|
||||
now,
|
||||
input.chat_jid,
|
||||
agentType,
|
||||
serviceId,
|
||||
input.delivery_role ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
export function createProducedWorkItemInDatabase(
|
||||
database: Database,
|
||||
input: CreateProducedWorkItemInput,
|
||||
@@ -278,46 +403,57 @@ export function createProducedWorkItemInDatabase(
|
||||
deliveryRole: input.delivery_role,
|
||||
serviceId: input.service_id,
|
||||
});
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO work_items (
|
||||
group_folder,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
service_id,
|
||||
delivery_role,
|
||||
status,
|
||||
start_seq,
|
||||
end_seq,
|
||||
result_payload,
|
||||
attachment_payload,
|
||||
delivery_attempts,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
input.group_folder,
|
||||
input.chat_jid,
|
||||
return database.transaction(() => {
|
||||
supersedeOpenWorkItemsForCanonicalKey(
|
||||
database,
|
||||
input,
|
||||
agentType,
|
||||
serviceId,
|
||||
input.delivery_role ?? null,
|
||||
input.start_seq,
|
||||
input.end_seq,
|
||||
input.result_payload,
|
||||
serializeAttachmentPayload(input.attachments),
|
||||
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,
|
||||
);
|
||||
.prepare(
|
||||
`INSERT INTO work_items (
|
||||
group_folder,
|
||||
chat_jid,
|
||||
agent_type,
|
||||
service_id,
|
||||
delivery_role,
|
||||
status,
|
||||
start_seq,
|
||||
end_seq,
|
||||
result_payload,
|
||||
attachment_payload,
|
||||
delivery_attempts,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
input.group_folder,
|
||||
input.chat_jid,
|
||||
agentType,
|
||||
serviceId,
|
||||
input.delivery_role ?? null,
|
||||
input.start_seq,
|
||||
input.end_seq,
|
||||
input.result_payload,
|
||||
serializeAttachmentPayload(input.attachments),
|
||||
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(
|
||||
|
||||
45
src/deploy-evidence.test.ts
Normal file
45
src/deploy-evidence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectArtifactMetadata,
|
||||
normalizeArtifactEvidenceKind,
|
||||
} from './deploy-evidence.js';
|
||||
|
||||
describe('deploy evidence helpers', () => {
|
||||
it('normalizes fixed artifact kinds', () => {
|
||||
expect(normalizeArtifactEvidenceKind()).toBe('build_outputs');
|
||||
expect(normalizeArtifactEvidenceKind('dashboard_dist')).toBe(
|
||||
'dashboard_dist',
|
||||
);
|
||||
expect(() => normalizeArtifactEvidenceKind('/etc/passwd')).toThrow(
|
||||
'Unsupported artifact evidence kind',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns metadata only for build artifacts', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-deploy-'));
|
||||
const dashboardDir = path.join(root, 'apps', 'dashboard', 'dist');
|
||||
fs.mkdirSync(path.join(dashboardDir, 'assets'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dashboardDir, 'index.html'), '<html></html>');
|
||||
fs.writeFileSync(path.join(dashboardDir, 'assets', 'index.js'), 'secret');
|
||||
|
||||
const text = collectArtifactMetadata(
|
||||
{
|
||||
projectRoot: root,
|
||||
dataDir: path.join(root, 'data'),
|
||||
dashboardStaticDir: dashboardDir,
|
||||
},
|
||||
{ action: 'ejclaw_artifact_metadata', artifactKind: 'dashboard_dist' },
|
||||
);
|
||||
|
||||
expect(text).toContain('"file_count"');
|
||||
expect(text).toContain('"total_bytes"');
|
||||
expect(text).toContain('index.js');
|
||||
expect(text).not.toContain('"secret"');
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
262
src/deploy-evidence.ts
Normal file
262
src/deploy-evidence.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { execFile } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
ARTIFACT_EVIDENCE_KINDS,
|
||||
DEPLOY_EVIDENCE_ACTIONS,
|
||||
isArtifactEvidenceKind,
|
||||
isDeployEvidenceAction,
|
||||
type ArtifactEvidenceKind,
|
||||
type DeployEvidenceAction,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
export {
|
||||
ARTIFACT_EVIDENCE_KINDS,
|
||||
DEPLOY_EVIDENCE_ACTIONS,
|
||||
isDeployEvidenceAction,
|
||||
type ArtifactEvidenceKind,
|
||||
type DeployEvidenceAction,
|
||||
};
|
||||
|
||||
export interface DeployEvidenceRequest {
|
||||
action: DeployEvidenceAction;
|
||||
artifactKind?: string;
|
||||
}
|
||||
|
||||
export interface DeployEvidencePaths {
|
||||
projectRoot: string;
|
||||
dataDir: string;
|
||||
dashboardStaticDir: string;
|
||||
}
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 5_000;
|
||||
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
||||
const MAX_DIR_ENTRIES = 5_000;
|
||||
const MAX_LATEST_FILES = 12;
|
||||
|
||||
export function normalizeArtifactEvidenceKind(
|
||||
value?: string,
|
||||
): ArtifactEvidenceKind {
|
||||
if (!value) return 'build_outputs';
|
||||
if (isArtifactEvidenceKind(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported artifact evidence kind: ${value}`);
|
||||
}
|
||||
|
||||
function execFileText(file: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
file,
|
||||
args,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
maxBuffer: COMMAND_MAX_BUFFER,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const details = stderr?.trim() || stdout?.trim() || error.message;
|
||||
reject(new Error(`${file} ${args.join(' ')} failed: ${details}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function sha256File(filePath: string): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(fs.readFileSync(filePath));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function fileMetadata(filePath: string): Record<string, unknown> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
path: filePath,
|
||||
exists: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
const base = {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
type: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other',
|
||||
size_bytes: stat.size,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
};
|
||||
|
||||
if (!stat.isFile()) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
sha256: sha256File(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
function walkDirectory(root: string): Array<{ path: string; stat: fs.Stats }> {
|
||||
if (!fs.existsSync(root)) return [];
|
||||
const pending = [root];
|
||||
const files: Array<{ path: string; stat: fs.Stats }> = [];
|
||||
|
||||
while (pending.length > 0 && files.length < MAX_DIR_ENTRIES) {
|
||||
const current = pending.pop()!;
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
files.push({ path: entryPath, stat: fs.statSync(entryPath) });
|
||||
if (files.length >= MAX_DIR_ENTRIES) break;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function directoryMetadata(root: string): Record<string, unknown> {
|
||||
if (!fs.existsSync(root)) {
|
||||
return {
|
||||
path: root,
|
||||
exists: false,
|
||||
};
|
||||
}
|
||||
const files = walkDirectory(root);
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.stat.size, 0);
|
||||
const latestFiles = [...files]
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
|
||||
.slice(0, MAX_LATEST_FILES)
|
||||
.map((file) => ({
|
||||
relative_path: path.relative(root, file.path),
|
||||
size_bytes: file.stat.size,
|
||||
mtime: file.stat.mtime.toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
path: root,
|
||||
exists: true,
|
||||
file_count: files.length,
|
||||
total_bytes: totalBytes,
|
||||
truncated: files.length >= MAX_DIR_ENTRIES,
|
||||
latest_files: latestFiles,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOutputArtifacts(
|
||||
paths: DeployEvidencePaths,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
root_dist: fileMetadata(path.join(paths.projectRoot, 'dist', 'index.js')),
|
||||
dashboard_index: fileMetadata(
|
||||
path.join(paths.dashboardStaticDir, 'index.html'),
|
||||
),
|
||||
dashboard_assets: directoryMetadata(
|
||||
path.join(paths.dashboardStaticDir, 'assets'),
|
||||
),
|
||||
agent_runner: fileMetadata(
|
||||
path.join(
|
||||
paths.projectRoot,
|
||||
'runners',
|
||||
'agent-runner',
|
||||
'dist',
|
||||
'index.js',
|
||||
),
|
||||
),
|
||||
codex_runner: fileMetadata(
|
||||
path.join(
|
||||
paths.projectRoot,
|
||||
'runners',
|
||||
'codex-runner',
|
||||
'dist',
|
||||
'index.js',
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectDeployState(
|
||||
paths: DeployEvidencePaths,
|
||||
): Promise<string> {
|
||||
const [head, logLine, status] = await Promise.all([
|
||||
execFileText('git', ['-C', paths.projectRoot, 'rev-parse', 'HEAD']),
|
||||
execFileText('git', [
|
||||
'-C',
|
||||
paths.projectRoot,
|
||||
'log',
|
||||
'-1',
|
||||
'--oneline',
|
||||
'--decorate',
|
||||
]),
|
||||
execFileText('git', ['-C', paths.projectRoot, 'status', '--short']),
|
||||
]);
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
action: 'ejclaw_deploy_state',
|
||||
project_root: paths.projectRoot,
|
||||
git: {
|
||||
head,
|
||||
log: logLine,
|
||||
dirty: status.length > 0,
|
||||
status_short: status,
|
||||
},
|
||||
artifacts: buildOutputArtifacts(paths),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function collectArtifactMetadata(
|
||||
paths: DeployEvidencePaths,
|
||||
request: DeployEvidenceRequest,
|
||||
): string {
|
||||
const kind = normalizeArtifactEvidenceKind(request.artifactKind);
|
||||
const payload =
|
||||
kind === 'build_outputs'
|
||||
? buildOutputArtifacts(paths)
|
||||
: kind === 'dashboard_dist'
|
||||
? directoryMetadata(paths.dashboardStaticDir)
|
||||
: kind === 'runner_dist'
|
||||
? {
|
||||
agent_runner: directoryMetadata(
|
||||
path.join(paths.projectRoot, 'runners', 'agent-runner', 'dist'),
|
||||
),
|
||||
codex_runner: directoryMetadata(
|
||||
path.join(paths.projectRoot, 'runners', 'codex-runner', 'dist'),
|
||||
),
|
||||
}
|
||||
: kind === 'android_debug_apk'
|
||||
? fileMetadata(
|
||||
path.join(
|
||||
paths.projectRoot,
|
||||
'apps',
|
||||
'android',
|
||||
'app',
|
||||
'build',
|
||||
'outputs',
|
||||
'apk',
|
||||
'debug',
|
||||
'app-debug.apk',
|
||||
),
|
||||
)
|
||||
: directoryMetadata(path.join(paths.dataDir, 'attachments'));
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
action: 'ejclaw_artifact_metadata',
|
||||
artifact_kind: kind,
|
||||
payload,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
60
src/deploy-script.test.ts
Normal file
60
src/deploy-script.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type PackageJson = {
|
||||
dependencies?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
workspaces?: string[];
|
||||
};
|
||||
|
||||
function readPackageJson(relativePath: string): PackageJson {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.resolve(import.meta.dirname, relativePath), 'utf8'),
|
||||
) as PackageJson;
|
||||
}
|
||||
|
||||
describe('deploy script', () => {
|
||||
it('refreshes workspace dependencies after pulling before building', () => {
|
||||
const packageJson = readPackageJson('../package.json');
|
||||
|
||||
const deploy = packageJson.scripts?.deploy ?? '';
|
||||
const pullIndex = deploy.indexOf('git pull --ff-only');
|
||||
const rootInstallIndex = deploy.indexOf('bun install --frozen-lockfile');
|
||||
const buildIndex = deploy.indexOf('bun run build:all');
|
||||
|
||||
expect(pullIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(rootInstallIndex).toBeGreaterThan(pullIndex);
|
||||
expect(rootInstallIndex).toBeLessThan(buildIndex);
|
||||
expect(
|
||||
deploy.indexOf('bun install --frozen-lockfile', rootInstallIndex + 1),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it('uses workspace links for shared runner code instead of copied file dependencies', () => {
|
||||
const rootPackageJson = readPackageJson('../package.json');
|
||||
const agentRunnerPackageJson = readPackageJson(
|
||||
'../runners/agent-runner/package.json',
|
||||
);
|
||||
const codexRunnerPackageJson = readPackageJson(
|
||||
'../runners/codex-runner/package.json',
|
||||
);
|
||||
|
||||
expect(rootPackageJson.workspaces).toEqual(
|
||||
expect.arrayContaining([
|
||||
'runners/shared',
|
||||
'runners/agent-runner',
|
||||
'runners/codex-runner',
|
||||
]),
|
||||
);
|
||||
expect(rootPackageJson.dependencies?.['ejclaw-runners-shared']).toBe(
|
||||
'workspace:*',
|
||||
);
|
||||
expect(agentRunnerPackageJson.dependencies?.['ejclaw-runners-shared']).toBe(
|
||||
'workspace:*',
|
||||
);
|
||||
expect(codexRunnerPackageJson.dependencies?.['ejclaw-runners-shared']).toBe(
|
||||
'workspace:*',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
_setRegisteredGroupForTests,
|
||||
|
||||
@@ -190,7 +190,7 @@ export async function checkGitHubActionsRun(
|
||||
};
|
||||
}
|
||||
|
||||
let failedJobs: string[] = [];
|
||||
let failedJobs: string[];
|
||||
try {
|
||||
failedJobs = await fetchFailedJobs(metadata);
|
||||
} catch {
|
||||
|
||||
115
src/github-evidence.test.ts
Normal file
115
src/github-evidence.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGitHubEvidenceCommand,
|
||||
decodeGitHubContentBase64,
|
||||
normalizeGitHubRef,
|
||||
normalizeGitHubRepo,
|
||||
normalizeGitHubWorkflowPath,
|
||||
normalizePositiveInteger,
|
||||
} from './github-evidence.js';
|
||||
|
||||
describe('GitHub evidence helpers', () => {
|
||||
it('validates repo and numeric identifiers', () => {
|
||||
expect(normalizeGitHubRepo('owner/repo')).toBe('owner/repo');
|
||||
expect(() => normalizeGitHubRepo('../repo')).toThrow(
|
||||
'Unsupported GitHub repo',
|
||||
);
|
||||
expect(normalizePositiveInteger(12, 'pr_number')).toBe(12);
|
||||
expect(() => normalizePositiveInteger(0, 'pr_number')).toThrow(
|
||||
'Missing or invalid pr_number',
|
||||
);
|
||||
});
|
||||
|
||||
it('validates workflow evidence paths and refs', () => {
|
||||
expect(
|
||||
normalizeGitHubWorkflowPath('.github/workflows/display-analyzer.yml'),
|
||||
).toBe('.github/workflows/display-analyzer.yml');
|
||||
expect(normalizeGitHubWorkflowPath('.github/workflows/ci.yaml')).toBe(
|
||||
'.github/workflows/ci.yaml',
|
||||
);
|
||||
expect(() => normalizeGitHubWorkflowPath('README.md')).toThrow(
|
||||
'Unsupported GitHub workflow path',
|
||||
);
|
||||
expect(() =>
|
||||
normalizeGitHubWorkflowPath('.github/workflows/../ci.yml'),
|
||||
).toThrow('Unsupported GitHub workflow path');
|
||||
|
||||
expect(normalizeGitHubRef('prod')).toBe('prod');
|
||||
expect(normalizeGitHubRef('feature/display-analyzer')).toBe(
|
||||
'feature/display-analyzer',
|
||||
);
|
||||
expect(normalizeGitHubRef('00b971972e4b2e7ecf0c6d789f405fef7edeb258')).toBe(
|
||||
'00b971972e4b2e7ecf0c6d789f405fef7edeb258',
|
||||
);
|
||||
expect(() => normalizeGitHubRef('../prod')).toThrow(
|
||||
'Missing or invalid ref',
|
||||
);
|
||||
expect(() => normalizeGitHubRef('feature branch')).toThrow(
|
||||
'Missing or invalid ref',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds fixed gh commands', () => {
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_pr_status',
|
||||
repo: 'owner/repo',
|
||||
prNumber: 164,
|
||||
}).args,
|
||||
).toEqual([
|
||||
'pr',
|
||||
'view',
|
||||
'164',
|
||||
'--repo',
|
||||
'owner/repo',
|
||||
'--json',
|
||||
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_run_status',
|
||||
repo: 'owner/repo',
|
||||
runId: 123,
|
||||
}).args,
|
||||
).toContain('123');
|
||||
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_run_jobs',
|
||||
repo: 'owner/repo',
|
||||
runId: 123,
|
||||
}).args,
|
||||
).toEqual([
|
||||
'run',
|
||||
'view',
|
||||
'123',
|
||||
'--repo',
|
||||
'owner/repo',
|
||||
'--json',
|
||||
'databaseId,name,status,conclusion,url,headBranch,headSha,jobs',
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildGitHubEvidenceCommand({
|
||||
action: 'github_workflow_file',
|
||||
repo: 'owner/repo',
|
||||
workflowPath: '.github/workflows/display-analyzer-check.yml',
|
||||
ref: 'feature/display-analyzer',
|
||||
}),
|
||||
).toMatchObject({
|
||||
args: [
|
||||
'api',
|
||||
'repos/owner/repo/contents/.github/workflows/display-analyzer-check.yml?ref=feature%2Fdisplay-analyzer',
|
||||
'--jq',
|
||||
'.content',
|
||||
],
|
||||
decodeBase64Stdout: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes GitHub contents API workflow bodies', () => {
|
||||
expect(decodeGitHubContentBase64('bmFtZTogQ0kK')).toBe('name: CI\n');
|
||||
});
|
||||
});
|
||||
210
src/github-evidence.ts
Normal file
210
src/github-evidence.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { execFile } from 'child_process';
|
||||
import {
|
||||
GITHUB_EVIDENCE_ACTIONS,
|
||||
isGitHubEvidenceAction,
|
||||
type GitHubEvidenceAction,
|
||||
} from 'ejclaw-runners-shared';
|
||||
|
||||
export {
|
||||
GITHUB_EVIDENCE_ACTIONS,
|
||||
isGitHubEvidenceAction,
|
||||
type GitHubEvidenceAction,
|
||||
};
|
||||
|
||||
export interface GitHubEvidenceRequest {
|
||||
action: GitHubEvidenceAction;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
runId?: number;
|
||||
workflowPath?: string;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
interface GitHubCommandSpec {
|
||||
file: string;
|
||||
args: string[];
|
||||
commandText: string;
|
||||
decodeBase64Stdout?: boolean;
|
||||
}
|
||||
|
||||
const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/;
|
||||
const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
|
||||
const COMMAND_TIMEOUT_MS = 10_000;
|
||||
const COMMAND_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
const MAX_OUTPUT_CHARS = 24_000;
|
||||
|
||||
export function normalizeGitHubRepo(value?: string): string {
|
||||
const repo = value?.trim();
|
||||
if (!repo || repo.includes('..') || !REPO_PATTERN.test(repo)) {
|
||||
throw new Error(`Unsupported GitHub repo for evidence: ${value}`);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
export function normalizePositiveInteger(
|
||||
value: number | undefined,
|
||||
label: string,
|
||||
): number {
|
||||
if (value == null || !Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`Missing or invalid ${label} for GitHub evidence`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeGitHubWorkflowPath(value?: string): string {
|
||||
const workflowPath = value?.trim();
|
||||
if (!workflowPath || !WORKFLOW_PATH_PATTERN.test(workflowPath)) {
|
||||
throw new Error(`Unsupported GitHub workflow path for evidence: ${value}`);
|
||||
}
|
||||
return workflowPath;
|
||||
}
|
||||
|
||||
export function normalizeGitHubRef(value?: string): string {
|
||||
const ref = value?.trim();
|
||||
if (
|
||||
!ref ||
|
||||
ref.includes('..') ||
|
||||
ref.includes('//') ||
|
||||
ref.endsWith('/') ||
|
||||
!REF_PATTERN.test(ref)
|
||||
) {
|
||||
throw new Error(`Missing or invalid ref for GitHub evidence`);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
export function decodeGitHubContentBase64(value: string): string {
|
||||
return Buffer.from(value.replace(/\s+/g, ''), 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
export function buildGitHubEvidenceCommand(
|
||||
request: GitHubEvidenceRequest,
|
||||
): GitHubCommandSpec {
|
||||
const repo = normalizeGitHubRepo(request.repo);
|
||||
|
||||
switch (request.action) {
|
||||
case 'github_pr_status': {
|
||||
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
|
||||
const args = [
|
||||
'pr',
|
||||
'view',
|
||||
String(prNumber),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
|
||||
];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'github_pr_diff_stat': {
|
||||
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
|
||||
const args = ['pr', 'diff', String(prNumber), '--repo', repo, '--stat'];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'github_run_status': {
|
||||
const runId = normalizePositiveInteger(request.runId, 'run_id');
|
||||
const args = [
|
||||
'run',
|
||||
'view',
|
||||
String(runId),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'status,conclusion,name,displayTitle,url,headBranch,headSha,event,createdAt,updatedAt',
|
||||
];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'github_run_jobs': {
|
||||
const runId = normalizePositiveInteger(request.runId, 'run_id');
|
||||
const args = [
|
||||
'run',
|
||||
'view',
|
||||
String(runId),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'databaseId,name,status,conclusion,url,headBranch,headSha,jobs',
|
||||
];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
case 'github_workflow_file': {
|
||||
const workflowPath = normalizeGitHubWorkflowPath(request.workflowPath);
|
||||
const ref = normalizeGitHubRef(request.ref);
|
||||
const endpoint = `repos/${repo}/contents/${workflowPath}?ref=${encodeURIComponent(ref)}`;
|
||||
const args = ['api', endpoint, '--jq', '.content'];
|
||||
return {
|
||||
file: 'gh',
|
||||
args,
|
||||
commandText: `gh ${args.join(' ')}`,
|
||||
decodeBase64Stdout: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncateGitHubEvidenceText(value: string | undefined): string {
|
||||
if (!value) return '';
|
||||
if (value.length <= MAX_OUTPUT_CHARS) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
|
||||
}
|
||||
|
||||
export function runGitHubEvidenceCommand(
|
||||
request: GitHubEvidenceRequest,
|
||||
): Promise<{
|
||||
command: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const command = buildGitHubEvidenceCommand(request);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command.file,
|
||||
command.args,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
maxBuffer: COMMAND_MAX_BUFFER,
|
||||
env: process.env,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(
|
||||
Object.assign(error, {
|
||||
command: command.commandText,
|
||||
stdout: truncateGitHubEvidenceText(stdout),
|
||||
stderr: truncateGitHubEvidenceText(stderr),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const normalizedStdout = command.decodeBase64Stdout
|
||||
? decodeGitHubContentBase64(stdout)
|
||||
: stdout;
|
||||
resolve({
|
||||
command: command.commandText,
|
||||
stdout: truncateGitHubEvidenceText(normalizedStdout),
|
||||
stderr: truncateGitHubEvidenceText(stderr),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface GroupState {
|
||||
runPhase: RunPhase;
|
||||
runningTaskId: string | null;
|
||||
currentRunId: string | null;
|
||||
lastCloseRequest: { runId: string | null; reason: string | null } | null;
|
||||
directTerminalDeliveries: Map<string, string>;
|
||||
recentDirectTerminalDeliveries: Map<string, Map<string, string>>;
|
||||
pendingMessages: boolean;
|
||||
@@ -52,6 +53,7 @@ export function createGroupState(): GroupState {
|
||||
runPhase: 'idle',
|
||||
runningTaskId: null,
|
||||
currentRunId: null,
|
||||
lastCloseRequest: null,
|
||||
directTerminalDeliveries: new Map(),
|
||||
recentDirectTerminalDeliveries: new Map(),
|
||||
pendingMessages: false,
|
||||
@@ -139,6 +141,7 @@ export function transitionRunPhase(
|
||||
export function resetRunState(state: GroupState, groupJid: string): void {
|
||||
state.currentRunId = null;
|
||||
state.runningTaskId = null;
|
||||
state.lastCloseRequest = null;
|
||||
state.startedAt = null;
|
||||
state.process = null;
|
||||
state.processName = null;
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('GroupQueue', () => {
|
||||
let concurrentCount = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const processMessages = vi.fn(async (groupJid: string) => {
|
||||
const processMessages = vi.fn(async (_groupJid: string) => {
|
||||
concurrentCount++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
|
||||
// Simulate async work
|
||||
@@ -282,7 +282,7 @@ describe('GroupQueue', () => {
|
||||
let maxActive = 0;
|
||||
const completionCallbacks: Array<() => void> = [];
|
||||
|
||||
const processMessages = vi.fn(async (groupJid: string) => {
|
||||
const processMessages = vi.fn(async (_groupJid: string) => {
|
||||
activeCount++;
|
||||
maxActive = Math.max(maxActive, activeCount);
|
||||
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
|
||||
@@ -360,7 +360,7 @@ describe('GroupQueue', () => {
|
||||
const executionOrder: string[] = [];
|
||||
let resolveFirst: () => void;
|
||||
|
||||
const processMessages = vi.fn(async (groupJid: string) => {
|
||||
const processMessages = vi.fn(async (_groupJid: string) => {
|
||||
if (executionOrder.length === 0) {
|
||||
// First call: block until we release it
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -747,3 +747,47 @@ describe('GroupQueue', () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupQueue close reason tracking', () => {
|
||||
let queue: GroupQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
queue = new GroupQueue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('records the close reason for the active message run until it finishes', async () => {
|
||||
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
|
||||
let releaseRun!: (value: boolean) => void;
|
||||
let activeRunId = '';
|
||||
const blocker = new Promise<boolean>((resolve) => {
|
||||
releaseRun = resolve;
|
||||
});
|
||||
|
||||
queue.setProcessMessagesFn(
|
||||
vi.fn(async (_groupJid: string, context: GroupRunContext) => {
|
||||
activeRunId = context.runId;
|
||||
return await blocker;
|
||||
}),
|
||||
);
|
||||
queue.enqueueMessageCheck('group1@g.us', ipcDir);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
queue.closeStdin('group1@g.us', { reason: 'human-message-detected' });
|
||||
|
||||
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBe(
|
||||
'human-message-detected',
|
||||
);
|
||||
expect(queue.getCloseReasonForRun('group1@g.us', 'other-run')).toBeNull();
|
||||
|
||||
releaseRun(true);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +322,10 @@ export class GroupQueue {
|
||||
}
|
||||
return state.directTerminalDeliveries.get(senderRole) ?? null;
|
||||
}
|
||||
|
||||
getCloseReasonForRun(groupJid: string, runId: string): string | null {
|
||||
const closeRequest = this.getGroup(groupJid).lastCloseRequest;
|
||||
return closeRequest?.runId === runId ? closeRequest.reason : null;
|
||||
}
|
||||
hasRecordedDirectTerminalDeliveryForRun(
|
||||
groupJid: string,
|
||||
runId: string,
|
||||
@@ -342,7 +345,6 @@ export class GroupQueue {
|
||||
state.recentDirectTerminalDeliveries.get(runId)?.has(senderRole) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
private clearPostCloseTimers(state: GroupState): void {
|
||||
if (state.postCloseTermTimer) {
|
||||
clearTimeout(state.postCloseTermTimer);
|
||||
@@ -428,29 +430,27 @@ export class GroupQueue {
|
||||
}, POST_CLOSE_SIGKILL_DELAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the active agent process to wind down by writing a close sentinel.
|
||||
*/
|
||||
closeStdin(
|
||||
groupJid: string,
|
||||
metadata?: { runId?: string; reason?: string },
|
||||
): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
if (state.runPhase === 'idle' || !state.ipcDir) return;
|
||||
const runId = metadata?.runId ?? state.currentRunId;
|
||||
state.lastCloseRequest = { runId, reason: metadata?.reason ?? null };
|
||||
if (state.runPhase === 'running_messages') {
|
||||
transitionRunPhase(state, groupJid, 'closing_messages', {
|
||||
reason: metadata?.reason,
|
||||
runId: metadata?.runId ?? state.currentRunId,
|
||||
runId,
|
||||
});
|
||||
assertRunPhaseInvariants(state, groupJid);
|
||||
}
|
||||
|
||||
try {
|
||||
writeCloseSentinel(state.ipcDir);
|
||||
logger.info(
|
||||
{
|
||||
groupJid,
|
||||
runId: metadata?.runId ?? state.currentRunId,
|
||||
runId,
|
||||
ipcDir: state.ipcDir,
|
||||
reason: metadata?.reason ?? 'unspecified',
|
||||
},
|
||||
@@ -460,7 +460,7 @@ export class GroupQueue {
|
||||
logger.warn(
|
||||
{
|
||||
groupJid,
|
||||
runId: metadata?.runId ?? state.currentRunId,
|
||||
runId,
|
||||
ipcDir: state.ipcDir,
|
||||
reason: metadata?.reason ?? 'unspecified',
|
||||
err,
|
||||
|
||||
@@ -89,6 +89,17 @@ describe('host evidence IPC', () => {
|
||||
requestId: 'req-1',
|
||||
action: 'ejclaw_service_status',
|
||||
tailLines: undefined,
|
||||
taskId: undefined,
|
||||
minutes: undefined,
|
||||
limit: undefined,
|
||||
repo: undefined,
|
||||
prNumber: undefined,
|
||||
runId: undefined,
|
||||
workflowPath: undefined,
|
||||
ref: undefined,
|
||||
artifactKind: undefined,
|
||||
sourceGroup: 'other-group',
|
||||
isMain: false,
|
||||
});
|
||||
expect(response.requestId).toBe('req-1');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRoleRuntimeConfigEvidence,
|
||||
buildHostEvidenceCommand,
|
||||
clampHostEvidenceTailLines,
|
||||
isHostEvidenceAction,
|
||||
@@ -10,6 +11,14 @@ describe('host evidence helpers', () => {
|
||||
it('recognizes only allowlisted actions', () => {
|
||||
expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('ejclaw_service_logs')).toBe(true);
|
||||
expect(isHostEvidenceAction('ejclaw_role_runtime_config')).toBe(true);
|
||||
expect(isHostEvidenceAction('db_paired_task_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('db_recent_scheduled_tasks')).toBe(true);
|
||||
expect(isHostEvidenceAction('db_scheduled_task_runs')).toBe(true);
|
||||
expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true);
|
||||
expect(isHostEvidenceAction('github_pr_status')).toBe(true);
|
||||
expect(isHostEvidenceAction('github_run_jobs')).toBe(true);
|
||||
expect(isHostEvidenceAction('github_workflow_file')).toBe(true);
|
||||
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,4 +48,22 @@ describe('host evidence helpers', () => {
|
||||
expect.arrayContaining(['--user', '-u', 'ejclaw', '-n', '42']),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns role runtime config without secret-shaped fields', () => {
|
||||
const text = buildRoleRuntimeConfigEvidence();
|
||||
const parsed = JSON.parse(text) as {
|
||||
roles: {
|
||||
owner: { agent_type: string; effective_model: string };
|
||||
reviewer: { agent_type: string; effective_model: string };
|
||||
arbiter: { agent_type: string | null; effective_model: string | null };
|
||||
};
|
||||
};
|
||||
|
||||
expect(parsed.roles.owner.agent_type).toMatch(/^(codex|claude-code)$/);
|
||||
expect(parsed.roles.reviewer.agent_type).toMatch(/^(codex|claude-code)$/);
|
||||
expect(text).not.toMatch(/api[_-]?key/i);
|
||||
expect(text).not.toMatch(/token/i);
|
||||
expect(text).not.toMatch(/secret/i);
|
||||
expect(text).not.toMatch(/password/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,19 +2,61 @@ import { execFile } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
ARBITER_AGENT_TYPE,
|
||||
ARBITER_MODEL_CONFIG,
|
||||
ARBITER_SERVICE_ID,
|
||||
CLAUDE_SERVICE_ID,
|
||||
CODEX_MAIN_SERVICE_ID,
|
||||
CODEX_REVIEW_SERVICE_ID,
|
||||
CURRENT_RUNTIME_AGENT_TYPE,
|
||||
DEFAULT_CLAUDE_MODEL,
|
||||
DEFAULT_CODEX_MODEL,
|
||||
OWNER_AGENT_TYPE,
|
||||
OWNER_MODEL_CONFIG,
|
||||
REVIEWER_AGENT_TYPE,
|
||||
REVIEWER_MODEL_CONFIG,
|
||||
REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||
SERVICE_ID,
|
||||
SERVICE_SESSION_SCOPE,
|
||||
DATA_DIR,
|
||||
WEB_DASHBOARD,
|
||||
} from './config.js';
|
||||
import type { AgentType } from './types.js';
|
||||
import {
|
||||
HOST_EVIDENCE_ACTIONS,
|
||||
isHostEvidenceAction,
|
||||
type HostEvidenceAction,
|
||||
} from 'ejclaw-runners-shared';
|
||||
import {
|
||||
collectArtifactMetadata,
|
||||
collectDeployState,
|
||||
} from './deploy-evidence.js';
|
||||
import { isDbEvidenceAction, runDbEvidenceRequest } from './db-evidence.js';
|
||||
import { requireDatabase } from './db/runtime-database.js';
|
||||
import {
|
||||
isGitHubEvidenceAction,
|
||||
runGitHubEvidenceCommand,
|
||||
} from './github-evidence.js';
|
||||
import { resolveGroupIpcPath } from './group-folder.js';
|
||||
|
||||
export const HOST_EVIDENCE_ACTIONS = [
|
||||
'ejclaw_service_status',
|
||||
'ejclaw_service_logs',
|
||||
] as const;
|
||||
|
||||
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
|
||||
export { HOST_EVIDENCE_ACTIONS, isHostEvidenceAction, type HostEvidenceAction };
|
||||
|
||||
export interface HostEvidenceRequest {
|
||||
requestId: string;
|
||||
action: HostEvidenceAction;
|
||||
tailLines?: number;
|
||||
taskId?: string;
|
||||
minutes?: number;
|
||||
limit?: number;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
runId?: number;
|
||||
workflowPath?: string;
|
||||
ref?: string;
|
||||
artifactKind?: string;
|
||||
sourceGroup?: string;
|
||||
isMain?: boolean;
|
||||
}
|
||||
|
||||
export interface HostEvidenceResult {
|
||||
@@ -42,15 +84,7 @@ const MAX_LOG_TAIL_LINES = 200;
|
||||
const MAX_OUTPUT_CHARS = 16_000;
|
||||
const COMMAND_TIMEOUT_MS = 5_000;
|
||||
const COMMAND_MAX_BUFFER = 1024 * 1024;
|
||||
|
||||
export function isHostEvidenceAction(
|
||||
value: unknown,
|
||||
): value is HostEvidenceAction {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
HOST_EVIDENCE_ACTIONS.includes(value as HostEvidenceAction)
|
||||
);
|
||||
}
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
|
||||
export function clampHostEvidenceTailLines(value?: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -108,6 +142,10 @@ export function buildHostEvidenceCommand(
|
||||
commandText: `journalctl ${args.join(' ')}`,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Host evidence action has no shell command: ${request.action}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +157,95 @@ export function truncateHostEvidenceText(value: string | undefined): string {
|
||||
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
|
||||
}
|
||||
|
||||
function effectiveModelForAgentType(
|
||||
agentType: AgentType | null | undefined,
|
||||
configuredModel: string | undefined,
|
||||
): string | null {
|
||||
if (!agentType) {
|
||||
return null;
|
||||
}
|
||||
if (configuredModel) {
|
||||
return configuredModel;
|
||||
}
|
||||
return agentType === 'claude-code'
|
||||
? DEFAULT_CLAUDE_MODEL
|
||||
: DEFAULT_CODEX_MODEL;
|
||||
}
|
||||
|
||||
function ownerServiceIdForAgentType(agentType: AgentType): string {
|
||||
return agentType === 'claude-code'
|
||||
? CLAUDE_SERVICE_ID
|
||||
: CODEX_MAIN_SERVICE_ID;
|
||||
}
|
||||
|
||||
function buildRoleRuntimeConfigRole(
|
||||
role: 'owner' | 'reviewer' | 'arbiter',
|
||||
agentType: AgentType | null | undefined,
|
||||
modelConfig: {
|
||||
model?: string;
|
||||
effort?: string;
|
||||
fallbackEnabled: boolean;
|
||||
},
|
||||
serviceId: string | null,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
role,
|
||||
enabled: role !== 'arbiter' || Boolean(agentType),
|
||||
agent_type: agentType ?? null,
|
||||
service_id: serviceId,
|
||||
configured_model: modelConfig.model ?? null,
|
||||
effective_model: effectiveModelForAgentType(agentType, modelConfig.model),
|
||||
effort: modelConfig.effort ?? null,
|
||||
fallback_enabled: modelConfig.fallbackEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoleRuntimeConfigEvidence(): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
action: 'ejclaw_role_runtime_config',
|
||||
current_service: {
|
||||
service_id: SERVICE_ID,
|
||||
session_scope: SERVICE_SESSION_SCOPE,
|
||||
runtime_agent_type: CURRENT_RUNTIME_AGENT_TYPE,
|
||||
},
|
||||
defaults: {
|
||||
claude_model: DEFAULT_CLAUDE_MODEL,
|
||||
codex_model: DEFAULT_CODEX_MODEL,
|
||||
},
|
||||
services: {
|
||||
claude: CLAUDE_SERVICE_ID,
|
||||
codex_main: CODEX_MAIN_SERVICE_ID,
|
||||
codex_review: CODEX_REVIEW_SERVICE_ID,
|
||||
reviewer_for_type: REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||
arbiter: ARBITER_SERVICE_ID,
|
||||
},
|
||||
roles: {
|
||||
owner: buildRoleRuntimeConfigRole(
|
||||
'owner',
|
||||
OWNER_AGENT_TYPE,
|
||||
OWNER_MODEL_CONFIG,
|
||||
ownerServiceIdForAgentType(OWNER_AGENT_TYPE),
|
||||
),
|
||||
reviewer: buildRoleRuntimeConfigRole(
|
||||
'reviewer',
|
||||
REVIEWER_AGENT_TYPE,
|
||||
REVIEWER_MODEL_CONFIG,
|
||||
REVIEWER_SERVICE_ID_FOR_TYPE,
|
||||
),
|
||||
arbiter: buildRoleRuntimeConfigRole(
|
||||
'arbiter',
|
||||
ARBITER_AGENT_TYPE,
|
||||
ARBITER_MODEL_CONFIG,
|
||||
ARBITER_SERVICE_ID,
|
||||
),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function execFileCapture(
|
||||
file: string,
|
||||
args: string[],
|
||||
@@ -164,9 +291,110 @@ function extractExitCode(error: unknown): number {
|
||||
export async function runHostEvidenceRequest(
|
||||
request: HostEvidenceRequest,
|
||||
): Promise<HostEvidenceResult> {
|
||||
const command = buildHostEvidenceCommand(request);
|
||||
|
||||
let commandText = '';
|
||||
try {
|
||||
if (request.action === 'ejclaw_role_runtime_config') {
|
||||
commandText = `internal:${request.action}`;
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: commandText,
|
||||
stdout: truncateHostEvidenceText(buildRoleRuntimeConfigEvidence()),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDbEvidenceAction(request.action)) {
|
||||
commandText = `internal:${request.action}`;
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: commandText,
|
||||
stdout: truncateHostEvidenceText(
|
||||
runDbEvidenceRequest(
|
||||
requireDatabase(),
|
||||
{
|
||||
action: request.action,
|
||||
taskId: request.taskId,
|
||||
minutes: request.minutes,
|
||||
limit: request.limit,
|
||||
},
|
||||
{
|
||||
sourceGroup: request.sourceGroup ?? '',
|
||||
isMain: request.isMain === true,
|
||||
},
|
||||
),
|
||||
),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.action === 'ejclaw_deploy_state') {
|
||||
commandText = `internal:${request.action}`;
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: commandText,
|
||||
stdout: truncateHostEvidenceText(
|
||||
await collectDeployState({
|
||||
projectRoot: PROJECT_ROOT,
|
||||
dataDir: DATA_DIR,
|
||||
dashboardStaticDir: WEB_DASHBOARD.staticDir,
|
||||
}),
|
||||
),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.action === 'ejclaw_artifact_metadata') {
|
||||
commandText = `internal:${request.action}`;
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: commandText,
|
||||
stdout: truncateHostEvidenceText(
|
||||
collectArtifactMetadata(
|
||||
{
|
||||
projectRoot: PROJECT_ROOT,
|
||||
dataDir: DATA_DIR,
|
||||
dashboardStaticDir: WEB_DASHBOARD.staticDir,
|
||||
},
|
||||
{
|
||||
action: request.action,
|
||||
artifactKind: request.artifactKind,
|
||||
},
|
||||
),
|
||||
),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (isGitHubEvidenceAction(request.action)) {
|
||||
const githubResult = await runGitHubEvidenceCommand({
|
||||
action: request.action,
|
||||
repo: request.repo,
|
||||
prNumber: request.prNumber,
|
||||
runId: request.runId,
|
||||
workflowPath: request.workflowPath,
|
||||
ref: request.ref,
|
||||
});
|
||||
commandText = githubResult.command;
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: commandText,
|
||||
stdout: truncateHostEvidenceText(githubResult.stdout),
|
||||
stderr: truncateHostEvidenceText(githubResult.stderr),
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const command = buildHostEvidenceCommand(request);
|
||||
commandText = command.commandText;
|
||||
const { stdout, stderr } = await execFileCapture(
|
||||
command.file,
|
||||
command.args,
|
||||
@@ -174,7 +402,7 @@ export async function runHostEvidenceRequest(
|
||||
return {
|
||||
ok: true,
|
||||
action: request.action,
|
||||
command: command.commandText,
|
||||
command: commandText,
|
||||
stdout: truncateHostEvidenceText(stdout),
|
||||
stderr: truncateHostEvidenceText(stderr),
|
||||
exitCode: 0,
|
||||
@@ -192,7 +420,10 @@ export async function runHostEvidenceRequest(
|
||||
return {
|
||||
ok: false,
|
||||
action: request.action,
|
||||
command: command.commandText,
|
||||
command:
|
||||
typeof error === 'object' && error !== null && 'command' in error
|
||||
? String((error as { command?: unknown }).command ?? commandText)
|
||||
: commandText,
|
||||
stdout: truncateHostEvidenceText(stdout),
|
||||
stderr: truncateHostEvidenceText(stderr),
|
||||
exitCode: extractExitCode(error),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
editFormattedTrackedChannelMessage,
|
||||
isTerminalStatusMessage,
|
||||
sendFormattedChannelMessage,
|
||||
sendFormattedTrackedChannelMessage,
|
||||
} from './index.js';
|
||||
import { composeDashboardContent } from './dashboard-render.js';
|
||||
@@ -81,12 +80,6 @@ describe('index scheduler messaging helpers', () => {
|
||||
'msg-123',
|
||||
'<internal>only hidden</internal>',
|
||||
);
|
||||
await sendFormattedChannelMessage(
|
||||
[channel],
|
||||
'dc:test',
|
||||
'<internal>only hidden</internal>',
|
||||
);
|
||||
|
||||
expect(trackedResult).toBeNull();
|
||||
expect(sendAndTrack).not.toHaveBeenCalled();
|
||||
expect(editMessage).not.toHaveBeenCalled();
|
||||
|
||||
331
src/index.ts
331
src/index.ts
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DATA_DIR,
|
||||
ARBITER_SERVICE_ID,
|
||||
CLAUDE_SERVICE_ID,
|
||||
CODEX_MAIN_SERVICE_ID,
|
||||
CODEX_REVIEW_SERVICE_ID,
|
||||
IDLE_TIMEOUT,
|
||||
POLL_INTERVAL,
|
||||
SERVICE_ID,
|
||||
isSessionCommandSenderAllowed,
|
||||
STATUS_CHANNEL_ID,
|
||||
STATUS_UPDATE_INTERVAL,
|
||||
TIMEZONE,
|
||||
@@ -19,29 +21,25 @@ import {
|
||||
} from './channels/registry.js';
|
||||
import { writeGroupsSnapshot } from './agent-runner.js';
|
||||
import {
|
||||
type AssignRoomInput,
|
||||
getAllTasks,
|
||||
getEarliestUnansweredHumanSeq,
|
||||
hasRecentRestartAnnouncement,
|
||||
initDatabase,
|
||||
recoverInterruptedPairedTurnAttemptsForService,
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
import { composeDashboardContent } from './dashboard-render.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupIpcPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import {
|
||||
findChannel,
|
||||
findChannelByName,
|
||||
sanitizeForOutbound,
|
||||
normalizeMessageForDedupe,
|
||||
resolveChannelForDeliveryRole,
|
||||
} from './router.js';
|
||||
deliverCanonicalOutboundMessage,
|
||||
deliverIpcOutboundMessage,
|
||||
} from './ipc-outbound-delivery.js';
|
||||
import { findChannel, formatOutbound } from './router.js';
|
||||
import {
|
||||
buildRestartAnnouncement,
|
||||
buildInterruptedRestartAnnouncement,
|
||||
consumeRestartContext,
|
||||
getRecoverableInterruptedGroups,
|
||||
getInterruptedRecoveryCandidates,
|
||||
inferRecentRestartContext,
|
||||
type RestartContext,
|
||||
@@ -49,7 +47,6 @@ import {
|
||||
} from './restart-context.js';
|
||||
import {
|
||||
isSenderAllowed,
|
||||
isTriggerAllowed,
|
||||
loadSenderAllowlist,
|
||||
shouldDropMessage,
|
||||
} from './sender-allowlist.js';
|
||||
@@ -61,6 +58,10 @@ import { startWebDashboardServer } from './web-dashboard-server.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
||||
import {
|
||||
startCodexAccountRefreshLoop,
|
||||
stopCodexAccountRefreshLoop,
|
||||
} from './settings-store.js';
|
||||
import {
|
||||
hasAvailableClaudeToken,
|
||||
initTokenRotation,
|
||||
@@ -69,7 +70,7 @@ import {
|
||||
isBotMessageSourceKind,
|
||||
resolveInjectedMessageSourceKind,
|
||||
} from './message-source.js';
|
||||
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
|
||||
import { parseVisibleVerdict } from './paired-verdict.js';
|
||||
|
||||
export function isTerminalStatusMessage(text: string): boolean {
|
||||
return parseVisibleVerdict(text) !== 'continue';
|
||||
@@ -88,20 +89,6 @@ import { FAILOVER_MIN_DURATION_MS } from './config.js';
|
||||
|
||||
// Token rotation is initialized lazily on first use or at startup below
|
||||
|
||||
export async function sendFormattedChannelMessage(
|
||||
channels: Channel[],
|
||||
jid: string,
|
||||
rawText: string,
|
||||
): Promise<void> {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) {
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
||||
return;
|
||||
}
|
||||
const text = sanitizeForOutbound(rawText);
|
||||
if (text) await channel.sendMessage(jid, text);
|
||||
}
|
||||
|
||||
export async function sendFormattedTrackedChannelMessage(
|
||||
channels: Channel[],
|
||||
jid: string,
|
||||
@@ -112,7 +99,7 @@ export async function sendFormattedTrackedChannelMessage(
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot send tracked message');
|
||||
return null;
|
||||
}
|
||||
const text = sanitizeForOutbound(rawText);
|
||||
const text = formatOutbound(rawText);
|
||||
if (!text || !channel.sendAndTrack) return null;
|
||||
return channel.sendAndTrack(jid, text);
|
||||
}
|
||||
@@ -128,7 +115,7 @@ export async function editFormattedTrackedChannelMessage(
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message');
|
||||
return;
|
||||
}
|
||||
const text = sanitizeForOutbound(rawText);
|
||||
const text = formatOutbound(rawText);
|
||||
if (!text || !channel.editMessage) return;
|
||||
await channel.editMessage(jid, messageId, text);
|
||||
}
|
||||
@@ -154,6 +141,23 @@ const runtime = createMessageRuntime({
|
||||
clearSession: runtimeState.clearSession,
|
||||
});
|
||||
|
||||
async function deliverFormattedCanonicalMessage(
|
||||
jid: string,
|
||||
rawText: string,
|
||||
deliveryRole?: 'owner' | 'reviewer' | 'arbiter',
|
||||
): Promise<void> {
|
||||
const text = formatOutbound(rawText);
|
||||
if (!text) return;
|
||||
await deliverCanonicalOutboundMessage(
|
||||
{ jid, text, deliveryRole },
|
||||
{
|
||||
channels,
|
||||
roomBindings: runtimeState.getRoomBindings,
|
||||
log: logger,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available groups list for the agent.
|
||||
* Returns groups ordered by most recent activity.
|
||||
@@ -176,6 +180,22 @@ export function _setRoomBindings(
|
||||
runtimeState.setRoomBindings(groups);
|
||||
}
|
||||
|
||||
async function tryDeliverRestartAnnouncement(
|
||||
chatJid: string,
|
||||
rawText: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await deliverFormattedCanonicalMessage(chatJid, rawText);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, chatJid },
|
||||
'Skipped restart recovery announcement because no channel is connected',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function announceRestartRecovery(
|
||||
processStartedAtMs: number,
|
||||
): Promise<RestartContext | null> {
|
||||
@@ -190,23 +210,26 @@ async function announceRestartRecovery(
|
||||
return explicitContext;
|
||||
}
|
||||
|
||||
await sendFormattedChannelMessage(
|
||||
channels,
|
||||
explicitContext.chatJid,
|
||||
buildRestartAnnouncement(explicitContext),
|
||||
);
|
||||
logger.info(
|
||||
{ chatJid: explicitContext.chatJid },
|
||||
'Sent explicit restart recovery announcement',
|
||||
);
|
||||
if (
|
||||
await tryDeliverRestartAnnouncement(
|
||||
explicitContext.chatJid,
|
||||
buildRestartAnnouncement(explicitContext),
|
||||
)
|
||||
) {
|
||||
logger.info(
|
||||
{ chatJid: explicitContext.chatJid },
|
||||
'Sent explicit restart recovery announcement',
|
||||
);
|
||||
}
|
||||
|
||||
for (const interrupted of explicitContext.interruptedGroups ?? []) {
|
||||
for (const interrupted of getRecoverableInterruptedGroups(
|
||||
explicitContext,
|
||||
)) {
|
||||
if (interrupted.chatJid === explicitContext.chatJid) continue;
|
||||
if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) {
|
||||
continue;
|
||||
}
|
||||
await sendFormattedChannelMessage(
|
||||
channels,
|
||||
await tryDeliverRestartAnnouncement(
|
||||
interrupted.chatJid,
|
||||
buildInterruptedRestartAnnouncement(interrupted),
|
||||
);
|
||||
@@ -228,18 +251,65 @@ async function announceRestartRecovery(
|
||||
return null;
|
||||
}
|
||||
|
||||
await sendFormattedChannelMessage(
|
||||
channels,
|
||||
inferred.chatJid,
|
||||
inferred.lines.join('\n'),
|
||||
);
|
||||
logger.info(
|
||||
{ chatJid: inferred.chatJid },
|
||||
'Sent inferred restart recovery announcement',
|
||||
);
|
||||
if (
|
||||
await tryDeliverRestartAnnouncement(
|
||||
inferred.chatJid,
|
||||
inferred.lines.join('\n'),
|
||||
)
|
||||
) {
|
||||
logger.info(
|
||||
{ chatJid: inferred.chatJid },
|
||||
'Sent inferred restart recovery announcement',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queueInterruptedPairedTurnAttemptRecovery(): void {
|
||||
for (const candidate of recoverInterruptedPairedTurnAttemptsForService({
|
||||
serviceIds: [
|
||||
SERVICE_ID,
|
||||
CLAUDE_SERVICE_ID,
|
||||
CODEX_MAIN_SERVICE_ID,
|
||||
CODEX_REVIEW_SERVICE_ID,
|
||||
ARBITER_SERVICE_ID,
|
||||
].filter((serviceId): serviceId is string => Boolean(serviceId)),
|
||||
})) {
|
||||
const binding = runtimeState.getRoomBindings()[candidate.chat_jid];
|
||||
if (!binding) {
|
||||
logger.warn(
|
||||
{
|
||||
chatJid: candidate.chat_jid,
|
||||
groupFolder: candidate.group_folder,
|
||||
taskId: candidate.task_id,
|
||||
turnId: candidate.turn_id,
|
||||
attemptId: candidate.attempt_id,
|
||||
},
|
||||
'Skipped interrupted paired turn recovery because room binding is missing',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
queue.enqueueMessageCheck(
|
||||
candidate.chat_jid,
|
||||
resolveGroupIpcPath(binding.folder),
|
||||
);
|
||||
logger.info(
|
||||
{
|
||||
chatJid: candidate.chat_jid,
|
||||
groupFolder: binding.folder,
|
||||
taskId: candidate.task_id,
|
||||
taskStatus: candidate.task_status,
|
||||
turnId: candidate.turn_id,
|
||||
attemptId: candidate.attempt_id,
|
||||
attemptNo: candidate.attempt_no,
|
||||
role: candidate.role,
|
||||
intentKind: candidate.intent_kind,
|
||||
},
|
||||
'Queued interrupted paired turn attempt for restart recovery',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const processStartedAtMs = Date.now();
|
||||
initDatabase();
|
||||
@@ -247,6 +317,7 @@ async function main(): Promise<void> {
|
||||
initTokenRotation();
|
||||
initCodexTokenRotation();
|
||||
startTokenRefreshLoop();
|
||||
startCodexAccountRefreshLoop();
|
||||
|
||||
runtimeState.loadState();
|
||||
|
||||
@@ -256,6 +327,7 @@ async function main(): Promise<void> {
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, 'Shutdown signal received');
|
||||
stopTokenRefreshLoop();
|
||||
stopCodexAccountRefreshLoop();
|
||||
if (leaseRecoveryTimer) {
|
||||
clearInterval(leaseRecoveryTimer);
|
||||
leaseRecoveryTimer = null;
|
||||
@@ -269,32 +341,17 @@ async function main(): Promise<void> {
|
||||
(
|
||||
status,
|
||||
): status is typeof status & {
|
||||
status: 'processing' | 'waiting';
|
||||
} => status.status !== 'inactive',
|
||||
status: 'processing';
|
||||
} => status.status === 'processing',
|
||||
)
|
||||
.map((status) => {
|
||||
// Capture the seq of the earliest unanswered human message for this
|
||||
// chat so the next startup can rewind the agent cursor and re-run
|
||||
// the interrupted turn automatically.
|
||||
let rewindToSeq: number | null = null;
|
||||
try {
|
||||
rewindToSeq = getEarliestUnansweredHumanSeq(status.jid);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, chatJid: status.jid },
|
||||
'Failed to capture rewind seq for shutdown snapshot',
|
||||
);
|
||||
}
|
||||
return {
|
||||
chatJid: status.jid,
|
||||
groupName: roomBindings[status.jid]?.name || status.jid,
|
||||
status: status.status,
|
||||
elapsedMs: status.elapsedMs,
|
||||
pendingMessages: status.pendingMessages,
|
||||
pendingTasks: status.pendingTasks,
|
||||
rewindToSeq,
|
||||
};
|
||||
});
|
||||
.map((status) => ({
|
||||
chatJid: status.jid,
|
||||
groupName: roomBindings[status.jid]?.name || status.jid,
|
||||
status: status.status,
|
||||
elapsedMs: status.elapsedMs,
|
||||
pendingMessages: status.pendingMessages,
|
||||
pendingTasks: status.pendingTasks,
|
||||
}));
|
||||
const writtenPaths = writeShutdownRestartContext(
|
||||
roomBindings,
|
||||
interruptedGroups,
|
||||
@@ -362,22 +419,28 @@ async function main(): Promise<void> {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
channels.push(channel);
|
||||
await channel.connect();
|
||||
try {
|
||||
await channel.connect();
|
||||
channels.push(channel);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ channel: channelName, err },
|
||||
'Channel connect failed — skipping',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (channels.length === 0) {
|
||||
logger.fatal('No channels connected');
|
||||
process.exit(1);
|
||||
if (WEB_DASHBOARD.enabled) {
|
||||
logger.warn(
|
||||
'No channels connected; continuing in web-dashboard-only mode',
|
||||
);
|
||||
} else {
|
||||
logger.fatal('No channels connected');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start subsystems (independently of connection handler)
|
||||
// Paired-room scheduler output goes through the reviewer bot slot.
|
||||
const reviewerChannelName = 'discord-review';
|
||||
const reviewerChannelForCron = findChannelByName(
|
||||
channels,
|
||||
reviewerChannelName,
|
||||
);
|
||||
|
||||
startSchedulerLoop({
|
||||
roomBindings: runtimeState.getRoomBindings,
|
||||
getSessions: runtimeState.getSessions,
|
||||
@@ -385,58 +448,25 @@ async function main(): Promise<void> {
|
||||
onProcess: (groupJid, proc, processName, ipcDir) =>
|
||||
queue.registerProcess(groupJid, proc, processName, ipcDir),
|
||||
sendMessage: (jid, rawText) =>
|
||||
sendFormattedChannelMessage(channels, jid, rawText),
|
||||
sendMessageViaReviewerBot: reviewerChannelForCron
|
||||
? async (jid, rawText) => {
|
||||
const text = sanitizeForOutbound(rawText);
|
||||
if (text) await reviewerChannelForCron.sendMessage(jid, text);
|
||||
}
|
||||
: undefined,
|
||||
deliverFormattedCanonicalMessage(jid, rawText),
|
||||
sendMessageViaReviewerBot: (jid, rawText) =>
|
||||
deliverFormattedCanonicalMessage(jid, rawText, 'reviewer'),
|
||||
sendTrackedMessage: (jid, rawText) =>
|
||||
sendFormattedTrackedChannelMessage(channels, jid, rawText),
|
||||
editTrackedMessage: (jid, messageId, rawText) =>
|
||||
editFormattedTrackedChannelMessage(channels, jid, messageId, rawText),
|
||||
});
|
||||
startIpcWatcher({
|
||||
sendMessage: async (jid, text, senderRole, runId) => {
|
||||
if (
|
||||
runId &&
|
||||
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
|
||||
queue.hasRecordedDirectTerminalDeliveryForRun(jid, runId, senderRole)
|
||||
) {
|
||||
logger.info(
|
||||
{
|
||||
transition: 'ipc:skip-post-terminal',
|
||||
chatJid: jid,
|
||||
senderRole,
|
||||
runId,
|
||||
},
|
||||
'Skipped IPC relay message because the run already emitted a direct terminal verdict',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const route = resolveChannelForDeliveryRole(channels, jid, senderRole);
|
||||
if (!route.channel) throw new Error(`No channel for JID: ${jid}`);
|
||||
logger.info(
|
||||
sendMessage: async (jid, text, senderRole, runId, attachments) => {
|
||||
await deliverIpcOutboundMessage(
|
||||
{ jid, text, senderRole, runId, attachments },
|
||||
{
|
||||
transition: 'ipc:route',
|
||||
chatJid: jid,
|
||||
runId: runId ?? null,
|
||||
senderRole: senderRole ?? null,
|
||||
requestedRoleChannel: route.requestedRoleChannelName,
|
||||
selectedChannel: route.selectedChannelName,
|
||||
usedRoleChannel: route.usedRoleChannel,
|
||||
fallbackUsed: route.fallbackUsed,
|
||||
channels,
|
||||
roomBindings: runtimeState.getRoomBindings,
|
||||
queue,
|
||||
log: logger,
|
||||
},
|
||||
'IPC relay routed message to channel',
|
||||
);
|
||||
await route.channel.sendMessage(jid, text);
|
||||
if (
|
||||
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
|
||||
isTerminalStatusMessage(text)
|
||||
) {
|
||||
queue.noteDirectTerminalDelivery(jid, senderRole, text);
|
||||
}
|
||||
},
|
||||
injectInboundMessage: async (payload) => {
|
||||
const jid = payload.chatJid;
|
||||
@@ -498,6 +528,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
queue.setProcessMessagesFn(runtime.processGroupMessages);
|
||||
queue.enterRecoveryMode();
|
||||
queueInterruptedPairedTurnAttemptRecovery();
|
||||
runtime.recoverPendingMessages();
|
||||
const restartContext = await announceRestartRecovery(processStartedAtMs);
|
||||
const roomBindings = runtimeState.getRoomBindings();
|
||||
@@ -505,37 +536,6 @@ async function main(): Promise<void> {
|
||||
restartContext,
|
||||
roomBindings,
|
||||
)) {
|
||||
// Auto-resume: rewind the agent cursor to just before the earliest
|
||||
// unanswered human message so the missed-message processor picks it up
|
||||
// again. Claude/Codex sessions are persisted by group folder, so the
|
||||
// agent will resume mid-turn rather than restarting from scratch.
|
||||
if (candidate.rewindToSeq != null && candidate.rewindToSeq > 0) {
|
||||
const cursors = runtimeState.getLastAgentTimestamps();
|
||||
const previousCursor = cursors[candidate.chatJid];
|
||||
const targetCursor = String(candidate.rewindToSeq - 1);
|
||||
// Only rewind if the current cursor is past the target — never advance.
|
||||
const previousNum = previousCursor
|
||||
? Number.parseInt(previousCursor, 10)
|
||||
: 0;
|
||||
if (
|
||||
Number.isFinite(previousNum) &&
|
||||
previousNum >= candidate.rewindToSeq
|
||||
) {
|
||||
cursors[candidate.chatJid] = targetCursor;
|
||||
runtimeState.saveState();
|
||||
logger.info(
|
||||
{
|
||||
chatJid: candidate.chatJid,
|
||||
groupFolder: candidate.groupFolder,
|
||||
previousCursor,
|
||||
rewoundTo: targetCursor,
|
||||
rewindToSeq: candidate.rewindToSeq,
|
||||
},
|
||||
'Rewound agent cursor for interrupted-turn auto-resume',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
queue.enqueueMessageCheck(
|
||||
candidate.chatJid,
|
||||
resolveGroupIpcPath(candidate.groupFolder),
|
||||
@@ -547,7 +547,6 @@ async function main(): Promise<void> {
|
||||
status: candidate.status,
|
||||
pendingMessages: candidate.pendingMessages,
|
||||
pendingTasks: candidate.pendingTasks,
|
||||
rewindToSeq: candidate.rewindToSeq,
|
||||
},
|
||||
'Queued interrupted group for restart recovery',
|
||||
);
|
||||
@@ -570,7 +569,13 @@ async function main(): Promise<void> {
|
||||
},
|
||||
purgeOnStart: true,
|
||||
});
|
||||
webDashboardServer = startWebDashboardServer(WEB_DASHBOARD);
|
||||
webDashboardServer = startWebDashboardServer({
|
||||
...WEB_DASHBOARD,
|
||||
getRoomBindings: runtimeState.getRoomBindings,
|
||||
enqueueMessageCheck: (chatJid, groupFolder) =>
|
||||
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
|
||||
nudgeScheduler: nudgeSchedulerLoop,
|
||||
});
|
||||
startUsagePrimer();
|
||||
|
||||
leaseRecoveryTimer = setInterval(() => {
|
||||
|
||||
@@ -139,6 +139,34 @@ describe('schedule_task authorization', () => {
|
||||
expect(allTasks[0].agent_type).toBe('codex');
|
||||
});
|
||||
|
||||
it('stores the scheduling caller agent type when provided', async () => {
|
||||
groups['other@g.us'] = {
|
||||
...OTHER_GROUP,
|
||||
agentType: 'claude-code',
|
||||
};
|
||||
_setRegisteredGroupForTests('other@g.us', groups['other@g.us']);
|
||||
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'caller owned task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
targetJid: 'other@g.us',
|
||||
agent_type: 'codex',
|
||||
room_role: 'owner',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const allTasks = getAllTasks();
|
||||
expect(allTasks).toHaveLength(1);
|
||||
expect(allTasks[0].agent_type).toBe('codex');
|
||||
expect(allTasks[0].room_role).toBe('owner');
|
||||
});
|
||||
|
||||
it('non-main group cannot schedule for another group', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
@@ -356,6 +384,42 @@ describe('resume_task authorization', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- update_task behavior ---
|
||||
|
||||
describe('update_task behavior', () => {
|
||||
it('updates once tasks even when next_run is not recomputed', async () => {
|
||||
createTask({
|
||||
id: 'task-once-update',
|
||||
group_folder: 'other-group',
|
||||
chat_jid: 'other@g.us',
|
||||
prompt: 'original once task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
context_mode: 'isolated',
|
||||
next_run: '2025-06-01T00:00:00.000Z',
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'update_task',
|
||||
taskId: 'task-once-update',
|
||||
prompt: 'updated once task',
|
||||
schedule_value: '2025-07-01T00:00:00',
|
||||
},
|
||||
'other-group',
|
||||
false,
|
||||
deps,
|
||||
);
|
||||
|
||||
const task = getTaskById('task-once-update')!;
|
||||
expect(task.prompt).toBe('updated once task');
|
||||
expect(task.schedule_value).toBe('2025-07-01T00:00:00');
|
||||
expect(task.next_run).toBe('2025-06-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
// --- cancel_task authorization ---
|
||||
|
||||
describe('cancel_task authorization', () => {
|
||||
@@ -556,6 +620,7 @@ describe('IPC message authorization', () => {
|
||||
'review text',
|
||||
'reviewer',
|
||||
'run-reviewer-ipc',
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -565,6 +630,83 @@ describe('IPC message authorization', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards structured attachments through authorized IPC messages', async () => {
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
const attachments = [
|
||||
{
|
||||
path: '/tmp/ejclaw-room-mobile-list-390.png',
|
||||
name: 'room.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await forwardAuthorizedIpcMessage(
|
||||
{
|
||||
type: 'message',
|
||||
chatJid: 'other@g.us',
|
||||
text: '스크린샷입니다.',
|
||||
attachments,
|
||||
},
|
||||
'other-group',
|
||||
false,
|
||||
groups,
|
||||
sendMessage,
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
'other@g.us',
|
||||
'스크린샷입니다.',
|
||||
undefined,
|
||||
undefined,
|
||||
attachments,
|
||||
);
|
||||
expect(result.outcome).toBe('sent');
|
||||
});
|
||||
|
||||
it('normalizes raw EJClaw envelopes in authorized IPC messages', async () => {
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
|
||||
const result = await forwardAuthorizedIpcMessage(
|
||||
{
|
||||
type: 'message',
|
||||
chatJid: 'other@g.us',
|
||||
text: JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '첨부를 렌더링했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
'other-group',
|
||||
false,
|
||||
groups,
|
||||
sendMessage,
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
'other@g.us',
|
||||
'첨부를 렌더링했습니다.',
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||
name: 'bar-chart-label-fit-playwright.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(result.outcome).toBe('sent');
|
||||
});
|
||||
|
||||
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
const injectInboundMessage = vi.fn(async () => {});
|
||||
@@ -964,6 +1106,31 @@ describe('assign_room success', () => {
|
||||
expect(group!.folder).toBe('new-group');
|
||||
});
|
||||
|
||||
it('assign_room persists trigger metadata through IPC', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'assign_room',
|
||||
jid: 'triggered@g.us',
|
||||
name: 'Triggered Group',
|
||||
folder: 'triggered-group',
|
||||
trigger: '@Repro',
|
||||
requiresTrigger: true,
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(getStoredRoomSettings('triggered@g.us')).toMatchObject({
|
||||
trigger: '@Repro',
|
||||
requiresTrigger: true,
|
||||
});
|
||||
expect(getRegisteredGroup('triggered@g.us')).toMatchObject({
|
||||
trigger: '@Repro',
|
||||
requiresTrigger: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('assign_room auto-fills missing folder for single rooms without exposing trigger metadata', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
IpcMessageForwardResult,
|
||||
IpcMessagePayload,
|
||||
} from './ipc-types.js';
|
||||
import { normalizeAgentOutput } from './agent-protocol.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
export async function forwardAuthorizedIpcMessage(
|
||||
@@ -14,6 +15,7 @@ export async function forwardAuthorizedIpcMessage(
|
||||
text: string,
|
||||
senderRole?: string,
|
||||
runId?: string,
|
||||
attachments?: import('./types.js').OutboundAttachment[],
|
||||
) => Promise<void>,
|
||||
injectInboundMessage?: (payload: {
|
||||
chatJid: string;
|
||||
@@ -79,7 +81,25 @@ export async function forwardAuthorizedIpcMessage(
|
||||
};
|
||||
}
|
||||
|
||||
await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId);
|
||||
const normalized = normalizeAgentOutput(msg.text);
|
||||
if (normalized.output?.visibility === 'silent') {
|
||||
return {
|
||||
outcome: 'sent',
|
||||
chatJid: msg.chatJid,
|
||||
targetGroup: targetGroup?.folder ?? null,
|
||||
isMainOverride,
|
||||
senderRole: msg.senderRole ?? null,
|
||||
};
|
||||
}
|
||||
const structured =
|
||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||
const text = structured?.text ?? normalized.result ?? msg.text;
|
||||
const attachments =
|
||||
msg.attachments && msg.attachments.length > 0
|
||||
? msg.attachments
|
||||
: (structured?.attachments ?? undefined);
|
||||
|
||||
await sendMessage(msg.chatJid, text, msg.senderRole, msg.runId, attachments);
|
||||
return {
|
||||
outcome: 'sent',
|
||||
chatJid: msg.chatJid,
|
||||
|
||||
240
src/ipc-outbound-delivery.test.ts
Normal file
240
src/ipc-outbound-delivery.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
deliverCanonicalOutboundMessage,
|
||||
deliverIpcOutboundMessage,
|
||||
} from './ipc-outbound-delivery.js';
|
||||
import type { WorkItem } from './db/work-items.js';
|
||||
import type { Channel, RegisteredGroup } from './types.js';
|
||||
|
||||
function makeChannel(name = 'discord', owns = true): Channel {
|
||||
return {
|
||||
name,
|
||||
connect: async () => {},
|
||||
sendMessage: vi.fn(async () => {}),
|
||||
isConnected: () => true,
|
||||
ownsJid: () => owns,
|
||||
disconnect: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroup(overrides: Partial<RegisteredGroup> = {}): RegisteredGroup {
|
||||
return {
|
||||
name: 'Room',
|
||||
folder: 'room-folder',
|
||||
added_at: '2026-04-28T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
workDir: '/repo',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWorkItem(input: {
|
||||
chat_jid: string;
|
||||
group_folder: string;
|
||||
agent_type?: WorkItem['agent_type'];
|
||||
delivery_role?: WorkItem['delivery_role'];
|
||||
result_payload: string;
|
||||
attachments?: WorkItem['attachments'];
|
||||
}): WorkItem {
|
||||
return {
|
||||
id: 123,
|
||||
group_folder: input.group_folder,
|
||||
chat_jid: input.chat_jid,
|
||||
agent_type: input.agent_type ?? 'codex',
|
||||
service_id: 'codex-main',
|
||||
delivery_role: input.delivery_role ?? null,
|
||||
status: 'produced',
|
||||
start_seq: null,
|
||||
end_seq: null,
|
||||
result_payload: input.result_payload,
|
||||
attachments: input.attachments ?? [],
|
||||
delivery_attempts: 0,
|
||||
delivery_message_id: null,
|
||||
last_error: null,
|
||||
created_at: '2026-04-28T00:00:00.000Z',
|
||||
updated_at: '2026-04-28T00:00:00.000Z',
|
||||
delivered_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('deliverIpcOutboundMessage', () => {
|
||||
it('funnels ordinary outbound through canonical work item delivery', async () => {
|
||||
const channel = makeChannel();
|
||||
const createdItem = makeWorkItem({
|
||||
chat_jid: 'dc:room',
|
||||
group_folder: 'room-folder',
|
||||
result_payload: '일반 안내',
|
||||
});
|
||||
const createWorkItem = vi.fn(() => createdItem);
|
||||
const deliverWorkItem = vi.fn(async () => true);
|
||||
|
||||
const result = await deliverCanonicalOutboundMessage(
|
||||
{ jid: 'dc:room', text: '일반 안내' },
|
||||
{
|
||||
channels: [channel],
|
||||
roomBindings: () => ({ 'dc:room': makeGroup() }),
|
||||
createWorkItem,
|
||||
deliverWorkItem,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe('delivered');
|
||||
expect(createWorkItem).toHaveBeenCalledWith({
|
||||
group_folder: 'room-folder',
|
||||
chat_jid: 'dc:room',
|
||||
agent_type: 'codex',
|
||||
delivery_role: null,
|
||||
start_seq: null,
|
||||
end_seq: null,
|
||||
result_payload: '일반 안내',
|
||||
attachments: undefined,
|
||||
});
|
||||
expect(deliverWorkItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel,
|
||||
item: createdItem,
|
||||
attachmentBaseDirs: expect.arrayContaining([
|
||||
'/repo',
|
||||
expect.stringMatching(/data\/workspaces\/room-folder$/),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(channel.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('funnels IPC outbound through canonical work item delivery', async () => {
|
||||
const ownerChannel = makeChannel();
|
||||
const reviewerChannel = makeChannel('discord-review', false);
|
||||
const createdItem = makeWorkItem({
|
||||
chat_jid: 'dc:room',
|
||||
group_folder: 'room-folder',
|
||||
delivery_role: 'reviewer',
|
||||
result_payload: 'TASK_DONE\n검증 완료',
|
||||
});
|
||||
const createWorkItem = vi.fn(() => createdItem);
|
||||
const deliverWorkItem = vi.fn(async () => true);
|
||||
const noteDirectTerminalDelivery = vi.fn();
|
||||
|
||||
const result = await deliverIpcOutboundMessage(
|
||||
{
|
||||
jid: 'dc:room',
|
||||
text: 'TASK_DONE\n검증 완료',
|
||||
senderRole: 'reviewer',
|
||||
runId: 'run-1',
|
||||
},
|
||||
{
|
||||
channels: [ownerChannel, reviewerChannel],
|
||||
roomBindings: () => ({ 'dc:room': makeGroup() }),
|
||||
queue: { noteDirectTerminalDelivery },
|
||||
createWorkItem,
|
||||
deliverWorkItem,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe('delivered');
|
||||
expect(createWorkItem).toHaveBeenCalledWith({
|
||||
group_folder: 'room-folder',
|
||||
chat_jid: 'dc:room',
|
||||
agent_type: 'codex',
|
||||
delivery_role: 'reviewer',
|
||||
start_seq: null,
|
||||
end_seq: null,
|
||||
result_payload: 'TASK_DONE\n검증 완료',
|
||||
attachments: undefined,
|
||||
});
|
||||
expect(deliverWorkItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: reviewerChannel,
|
||||
item: createdItem,
|
||||
attachmentBaseDirs: expect.arrayContaining([
|
||||
'/repo',
|
||||
expect.stringMatching(/data\/workspaces\/room-folder$/),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(noteDirectTerminalDelivery).toHaveBeenCalledWith(
|
||||
'dc:room',
|
||||
'reviewer',
|
||||
'TASK_DONE\n검증 완료',
|
||||
);
|
||||
expect(ownerChannel.sendMessage).not.toHaveBeenCalled();
|
||||
expect(reviewerChannel.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps failed IPC outbound in the delivery retry queue', async () => {
|
||||
const channel = makeChannel();
|
||||
const createWorkItem = vi.fn((input) => makeWorkItem(input as any));
|
||||
const deliverWorkItem = vi.fn(async () => false);
|
||||
const noteDirectTerminalDelivery = vi.fn();
|
||||
|
||||
const result = await deliverIpcOutboundMessage(
|
||||
{
|
||||
jid: 'dc:room',
|
||||
text: 'TASK_DONE\n검증 완료',
|
||||
senderRole: 'reviewer',
|
||||
runId: 'run-1',
|
||||
},
|
||||
{
|
||||
channels: [channel],
|
||||
roomBindings: () => ({ 'dc:room': makeGroup() }),
|
||||
queue: { noteDirectTerminalDelivery },
|
||||
createWorkItem,
|
||||
deliverWorkItem,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe('queued_retry');
|
||||
expect(createWorkItem).toHaveBeenCalledTimes(1);
|
||||
expect(deliverWorkItem).toHaveBeenCalledTimes(1);
|
||||
expect(noteDirectTerminalDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create non-canonical outbound for unregistered rooms', async () => {
|
||||
const createWorkItem = vi.fn((input) => makeWorkItem(input as any));
|
||||
const deliverWorkItem = vi.fn(async () => true);
|
||||
|
||||
await expect(
|
||||
deliverIpcOutboundMessage(
|
||||
{ jid: 'dc:missing', text: 'hello' },
|
||||
{
|
||||
channels: [makeChannel()],
|
||||
roomBindings: () => ({}),
|
||||
queue: {},
|
||||
createWorkItem,
|
||||
deliverWorkItem,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('No registered room binding for outbound JID');
|
||||
|
||||
expect(createWorkItem).not.toHaveBeenCalled();
|
||||
expect(deliverWorkItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips duplicate terminal IPC messages already recorded for the run', async () => {
|
||||
const createWorkItem = vi.fn((input) => makeWorkItem(input as any));
|
||||
const deliverWorkItem = vi.fn(async () => true);
|
||||
|
||||
const result = await deliverIpcOutboundMessage(
|
||||
{
|
||||
jid: 'dc:room',
|
||||
text: 'TASK_DONE\n검증 완료',
|
||||
senderRole: 'reviewer',
|
||||
runId: 'run-1',
|
||||
},
|
||||
{
|
||||
channels: [makeChannel()],
|
||||
roomBindings: () => ({ 'dc:room': makeGroup() }),
|
||||
queue: {
|
||||
hasRecordedDirectTerminalDeliveryForRun: () => true,
|
||||
},
|
||||
createWorkItem,
|
||||
deliverWorkItem,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe('skipped_recorded_terminal');
|
||||
expect(createWorkItem).not.toHaveBeenCalled();
|
||||
expect(deliverWorkItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
193
src/ipc-outbound-delivery.ts
Normal file
193
src/ipc-outbound-delivery.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { createProducedWorkItem } from './db.js';
|
||||
import type { WorkItem } from './db/work-items.js';
|
||||
import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js';
|
||||
import { deliverOpenWorkItem } from './message-runtime-delivery.js';
|
||||
import { parseVisibleVerdict } from './paired-verdict.js';
|
||||
import { resolveChannelForDeliveryRole } from './router.js';
|
||||
import { normalizePairedRoomRole } from './types.js';
|
||||
import type {
|
||||
Channel,
|
||||
OutboundAttachment,
|
||||
PairedRoomRole,
|
||||
RegisteredGroup,
|
||||
} from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
type IpcDeliveryLog = Pick<typeof logger, 'info' | 'warn' | 'error'>;
|
||||
|
||||
interface IpcOutboundQueue {
|
||||
hasRecordedDirectTerminalDeliveryForRun?(
|
||||
groupJid: string,
|
||||
runId: string,
|
||||
senderRole?: string | null,
|
||||
): boolean;
|
||||
noteDirectTerminalDelivery?(
|
||||
groupJid: string,
|
||||
senderRole?: string | null,
|
||||
text?: string | null,
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface IpcOutboundDeliveryArgs {
|
||||
jid: string;
|
||||
text: string;
|
||||
senderRole?: string;
|
||||
runId?: string;
|
||||
attachments?: OutboundAttachment[];
|
||||
}
|
||||
|
||||
export interface IpcOutboundDeliveryDeps {
|
||||
channels: Channel[];
|
||||
roomBindings: () => Record<string, RegisteredGroup>;
|
||||
queue: IpcOutboundQueue;
|
||||
log?: IpcDeliveryLog;
|
||||
createWorkItem?: typeof createProducedWorkItem;
|
||||
deliverWorkItem?: typeof deliverOpenWorkItem;
|
||||
}
|
||||
|
||||
export interface CanonicalOutboundDeliveryArgs {
|
||||
jid: string;
|
||||
text: string;
|
||||
deliveryRole?: PairedRoomRole;
|
||||
attachments?: OutboundAttachment[];
|
||||
}
|
||||
|
||||
export interface CanonicalOutboundDeliveryDeps {
|
||||
channels: Channel[];
|
||||
roomBindings: () => Record<string, RegisteredGroup>;
|
||||
log?: IpcDeliveryLog;
|
||||
createWorkItem?: typeof createProducedWorkItem;
|
||||
deliverWorkItem?: typeof deliverOpenWorkItem;
|
||||
}
|
||||
|
||||
export type CanonicalOutboundDeliveryResult = 'delivered' | 'queued_retry';
|
||||
|
||||
export type IpcOutboundDeliveryResult =
|
||||
| 'delivered'
|
||||
| 'queued_retry'
|
||||
| 'skipped_recorded_terminal';
|
||||
|
||||
function isTerminalStatusMessage(text: string): boolean {
|
||||
return parseVisibleVerdict(text) !== 'continue';
|
||||
}
|
||||
|
||||
export async function deliverCanonicalOutboundMessage(
|
||||
args: CanonicalOutboundDeliveryArgs,
|
||||
deps: CanonicalOutboundDeliveryDeps,
|
||||
): Promise<CanonicalOutboundDeliveryResult> {
|
||||
const log = deps.log ?? logger;
|
||||
const group = deps.roomBindings()[args.jid];
|
||||
if (!group) {
|
||||
throw new Error(`No registered room binding for outbound JID: ${args.jid}`);
|
||||
}
|
||||
|
||||
const route = resolveChannelForDeliveryRole(
|
||||
deps.channels,
|
||||
args.jid,
|
||||
args.deliveryRole,
|
||||
);
|
||||
if (!route.channel) throw new Error(`No channel for JID: ${args.jid}`);
|
||||
|
||||
log.info(
|
||||
{
|
||||
transition: 'outbound:route',
|
||||
chatJid: args.jid,
|
||||
deliveryRole: args.deliveryRole ?? null,
|
||||
requestedRoleChannel: route.requestedRoleChannelName,
|
||||
selectedChannel: route.selectedChannelName,
|
||||
usedRoleChannel: route.usedRoleChannel,
|
||||
fallbackUsed: route.fallbackUsed,
|
||||
},
|
||||
'Routed outbound message to canonical work item delivery',
|
||||
);
|
||||
|
||||
const createWorkItem = deps.createWorkItem ?? createProducedWorkItem;
|
||||
const workItem = createWorkItem({
|
||||
group_folder: group.folder,
|
||||
chat_jid: args.jid,
|
||||
agent_type: group.agentType ?? 'claude-code',
|
||||
delivery_role: args.deliveryRole ?? null,
|
||||
start_seq: null,
|
||||
end_seq: null,
|
||||
result_payload: args.text,
|
||||
attachments: args.attachments,
|
||||
});
|
||||
|
||||
const deliverWorkItem = deps.deliverWorkItem ?? deliverOpenWorkItem;
|
||||
const delivered = await deliverWorkItem({
|
||||
channel: route.channel,
|
||||
item: workItem as WorkItem,
|
||||
log,
|
||||
attachmentBaseDirs: resolveRuntimeAttachmentBaseDirs(group),
|
||||
isDuplicateOfLastBotFinal: () => false,
|
||||
openContinuation: () => {},
|
||||
});
|
||||
|
||||
return delivered ? 'delivered' : 'queued_retry';
|
||||
}
|
||||
|
||||
export async function deliverIpcOutboundMessage(
|
||||
args: IpcOutboundDeliveryArgs,
|
||||
deps: IpcOutboundDeliveryDeps,
|
||||
): Promise<IpcOutboundDeliveryResult> {
|
||||
const log = deps.log ?? logger;
|
||||
const deliveryRole = normalizePairedRoomRole(args.senderRole);
|
||||
if (
|
||||
args.runId &&
|
||||
(deliveryRole === 'reviewer' || deliveryRole === 'arbiter') &&
|
||||
deps.queue.hasRecordedDirectTerminalDeliveryForRun?.(
|
||||
args.jid,
|
||||
args.runId,
|
||||
deliveryRole,
|
||||
)
|
||||
) {
|
||||
log.info(
|
||||
{
|
||||
transition: 'ipc:skip-post-terminal',
|
||||
chatJid: args.jid,
|
||||
senderRole: deliveryRole,
|
||||
runId: args.runId,
|
||||
},
|
||||
'Skipped IPC relay message because the run already emitted a direct terminal verdict',
|
||||
);
|
||||
return 'skipped_recorded_terminal';
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
transition: 'ipc:route',
|
||||
chatJid: args.jid,
|
||||
runId: args.runId ?? null,
|
||||
senderRole: deliveryRole ?? null,
|
||||
},
|
||||
'IPC relay routed message to canonical work item delivery',
|
||||
);
|
||||
|
||||
const result = await deliverCanonicalOutboundMessage(
|
||||
{
|
||||
jid: args.jid,
|
||||
text: args.text,
|
||||
deliveryRole,
|
||||
attachments: args.attachments,
|
||||
},
|
||||
{
|
||||
channels: deps.channels,
|
||||
roomBindings: deps.roomBindings,
|
||||
log,
|
||||
createWorkItem: deps.createWorkItem,
|
||||
deliverWorkItem: deps.deliverWorkItem,
|
||||
},
|
||||
);
|
||||
|
||||
if (result !== 'delivered') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (
|
||||
(deliveryRole === 'reviewer' || deliveryRole === 'arbiter') &&
|
||||
isTerminalStatusMessage(args.text)
|
||||
) {
|
||||
deps.queue.noteDirectTerminalDelivery?.(args.jid, deliveryRole, args.text);
|
||||
}
|
||||
return 'delivered';
|
||||
}
|
||||
@@ -1,25 +1,16 @@
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { TIMEZONE } from './config.js';
|
||||
import {
|
||||
isHostEvidenceAction,
|
||||
runHostEvidenceRequest,
|
||||
writeHostEvidenceResponse,
|
||||
} from './host-evidence.js';
|
||||
import {
|
||||
createTask,
|
||||
deleteTask,
|
||||
findDuplicateCiWatcher,
|
||||
getTaskById,
|
||||
rememberMemory,
|
||||
updateTask,
|
||||
} from './db.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { deleteTask, updateTask } from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import { handleHostEvidenceRequest } from './ipc-task-processor/host-evidence.js';
|
||||
import { handlePersistMemory } from './ipc-task-processor/memory.js';
|
||||
import {
|
||||
DEFAULT_WATCH_CI_MAX_DURATION_MS,
|
||||
isWatchCiTask,
|
||||
} from './task-watch-status.js';
|
||||
handleAssignRoom,
|
||||
handleRefreshGroups,
|
||||
} from './ipc-task-processor/rooms.js';
|
||||
import { handleScheduleTask } from './ipc-task-processor/schedule.js';
|
||||
import {
|
||||
handleTaskStateMutation,
|
||||
handleUpdateTask,
|
||||
} from './ipc-task-processor/tasks.js';
|
||||
import type { IpcDeps, TaskIpcPayload } from './ipc-types.js';
|
||||
|
||||
export async function processTaskIpc(
|
||||
@@ -28,561 +19,54 @@ export async function processTaskIpc(
|
||||
isMain: boolean,
|
||||
deps: IpcDeps,
|
||||
): Promise<void> {
|
||||
const roomBindings = deps.roomBindings();
|
||||
|
||||
switch (data.type) {
|
||||
case 'schedule_task':
|
||||
if (
|
||||
data.prompt &&
|
||||
data.schedule_type &&
|
||||
data.schedule_value &&
|
||||
data.targetJid
|
||||
) {
|
||||
const targetJid = data.targetJid;
|
||||
const targetGroupEntry =
|
||||
roomBindings[targetJid] ||
|
||||
Object.values(roomBindings).find(
|
||||
(group) => group.folder === targetJid,
|
||||
);
|
||||
|
||||
if (!targetGroupEntry) {
|
||||
logger.warn(
|
||||
{ targetJid },
|
||||
'Cannot schedule task: target group not registered',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const targetFolder = targetGroupEntry.folder;
|
||||
const resolvedTargetJid =
|
||||
roomBindings[targetJid] !== undefined
|
||||
? targetJid
|
||||
: Object.entries(roomBindings).find(
|
||||
([, group]) => group.folder === targetFolder,
|
||||
)?.[0];
|
||||
|
||||
if (!resolvedTargetJid) {
|
||||
logger.warn(
|
||||
{ targetJid, targetFolder },
|
||||
'Cannot resolve scheduled task target JID from folder',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isMain && targetFolder !== sourceGroup) {
|
||||
logger.warn(
|
||||
{ sourceGroup, targetFolder },
|
||||
'Unauthorized schedule_task attempt blocked',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';
|
||||
|
||||
let nextRun: string | null = null;
|
||||
if (scheduleType === 'cron') {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(data.schedule_value, {
|
||||
tz: TIMEZONE,
|
||||
});
|
||||
nextRun = interval.next().toISOString();
|
||||
} catch {
|
||||
logger.warn(
|
||||
{ scheduleValue: data.schedule_value },
|
||||
'Invalid cron expression',
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else if (scheduleType === 'interval') {
|
||||
const ms = parseInt(data.schedule_value, 10);
|
||||
if (isNaN(ms) || ms <= 0) {
|
||||
logger.warn(
|
||||
{ scheduleValue: data.schedule_value },
|
||||
'Invalid interval',
|
||||
);
|
||||
break;
|
||||
}
|
||||
nextRun = isWatchCiTask({ prompt: data.prompt })
|
||||
? new Date().toISOString()
|
||||
: new Date(Date.now() + ms).toISOString();
|
||||
} else if (scheduleType === 'once') {
|
||||
const date = new Date(data.schedule_value);
|
||||
if (isNaN(date.getTime())) {
|
||||
logger.warn(
|
||||
{ scheduleValue: data.schedule_value },
|
||||
'Invalid timestamp',
|
||||
);
|
||||
break;
|
||||
}
|
||||
nextRun = date.toISOString();
|
||||
}
|
||||
|
||||
if (data.ci_provider && data.ci_metadata) {
|
||||
const existing = findDuplicateCiWatcher(
|
||||
resolvedTargetJid,
|
||||
data.ci_provider,
|
||||
data.ci_metadata,
|
||||
);
|
||||
if (existing) {
|
||||
logger.info(
|
||||
{
|
||||
existingTaskId: existing.id,
|
||||
existingAgentType: existing.agent_type,
|
||||
ciProvider: data.ci_provider,
|
||||
sourceGroup,
|
||||
},
|
||||
'Duplicate CI watcher skipped — another agent already watches this run',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const taskId =
|
||||
data.taskId ||
|
||||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const contextMode =
|
||||
data.context_mode === 'group' || data.context_mode === 'isolated'
|
||||
? data.context_mode
|
||||
: 'isolated';
|
||||
createTask({
|
||||
id: taskId,
|
||||
group_folder: targetFolder,
|
||||
chat_jid: resolvedTargetJid,
|
||||
agent_type: targetGroupEntry.agentType || 'claude-code',
|
||||
ci_provider: data.ci_provider ?? null,
|
||||
ci_metadata: data.ci_metadata ?? null,
|
||||
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
|
||||
? DEFAULT_WATCH_CI_MAX_DURATION_MS
|
||||
: null,
|
||||
prompt: data.prompt,
|
||||
schedule_type: scheduleType,
|
||||
schedule_value: data.schedule_value,
|
||||
context_mode: contextMode,
|
||||
next_run: nextRun,
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
taskId,
|
||||
sourceGroup,
|
||||
targetFolder,
|
||||
contextMode,
|
||||
agentType: targetGroupEntry.agentType || 'claude-code',
|
||||
},
|
||||
'Task created via IPC',
|
||||
);
|
||||
if (nextRun && new Date(nextRun).getTime() <= Date.now()) {
|
||||
deps.nudgeScheduler?.();
|
||||
}
|
||||
}
|
||||
handleScheduleTask(data, sourceGroup, isMain, deps);
|
||||
break;
|
||||
|
||||
case 'pause_task':
|
||||
if (data.taskId) {
|
||||
const task = getTaskById(data.taskId);
|
||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
||||
updateTask(data.taskId, { status: 'paused' });
|
||||
logger.info(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Task paused via IPC',
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Unauthorized task pause attempt',
|
||||
);
|
||||
}
|
||||
}
|
||||
handleTaskStateMutation(data, sourceGroup, isMain, {
|
||||
action: () => updateTask(data.taskId!, { status: 'paused' }),
|
||||
successMessage: 'Task paused via IPC',
|
||||
unauthorizedMessage: 'Unauthorized task pause attempt',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'resume_task':
|
||||
if (data.taskId) {
|
||||
const task = getTaskById(data.taskId);
|
||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
||||
updateTask(data.taskId, { status: 'active' });
|
||||
logger.info(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Task resumed via IPC',
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Unauthorized task resume attempt',
|
||||
);
|
||||
}
|
||||
}
|
||||
handleTaskStateMutation(data, sourceGroup, isMain, {
|
||||
action: () => updateTask(data.taskId!, { status: 'active' }),
|
||||
successMessage: 'Task resumed via IPC',
|
||||
unauthorizedMessage: 'Unauthorized task resume attempt',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'cancel_task':
|
||||
if (data.taskId) {
|
||||
const task = getTaskById(data.taskId);
|
||||
if (task && (isMain || task.group_folder === sourceGroup)) {
|
||||
deleteTask(data.taskId);
|
||||
logger.info(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Task cancelled via IPC',
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Unauthorized task cancel attempt',
|
||||
);
|
||||
}
|
||||
}
|
||||
handleTaskStateMutation(data, sourceGroup, isMain, {
|
||||
action: () => deleteTask(data.taskId!),
|
||||
successMessage: 'Task cancelled via IPC',
|
||||
unauthorizedMessage: 'Unauthorized task cancel attempt',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'host_evidence_request':
|
||||
if (!data.requestId) {
|
||||
logger.warn(
|
||||
{ sourceGroup },
|
||||
'Ignoring host_evidence_request without requestId',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (data.action === 'ejclaw_room_runtime') {
|
||||
if (!data.chatJid) {
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId,
|
||||
ok: false,
|
||||
action: 'ejclaw_room_runtime',
|
||||
command: 'internal:ejclaw_room_runtime',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
error: 'Missing chatJid for ejclaw_room_runtime request',
|
||||
});
|
||||
logger.warn(
|
||||
{ sourceGroup, requestId: data.requestId },
|
||||
'Rejected ejclaw_room_runtime request without chatJid',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!deps.getRoomRuntimeReport) {
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId,
|
||||
ok: false,
|
||||
action: 'ejclaw_room_runtime',
|
||||
command: 'internal:ejclaw_room_runtime',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
error: 'Room runtime reporting is not configured',
|
||||
});
|
||||
logger.warn(
|
||||
{ sourceGroup, requestId: data.requestId, chatJid: data.chatJid },
|
||||
'Rejected ejclaw_room_runtime request because runtime reporter is unavailable',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const report = deps.getRoomRuntimeReport({
|
||||
chatJid: data.chatJid,
|
||||
sourceGroup,
|
||||
isMain,
|
||||
});
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId,
|
||||
ok: true,
|
||||
action: 'ejclaw_room_runtime',
|
||||
command: 'internal:ejclaw_room_runtime',
|
||||
stdout: JSON.stringify(report, null, 2),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
sourceGroup,
|
||||
requestId: data.requestId,
|
||||
action: data.action,
|
||||
chatJid: data.chatJid,
|
||||
},
|
||||
'Processed room runtime report request via IPC',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isHostEvidenceAction(data.action)) {
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId,
|
||||
ok: false,
|
||||
action: 'ejclaw_service_status',
|
||||
command: '',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
error: `Unsupported host evidence action: ${String(data.action)}`,
|
||||
});
|
||||
logger.warn(
|
||||
{ sourceGroup, requestId: data.requestId, action: data.action },
|
||||
'Rejected unsupported host evidence action',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
{
|
||||
const result = await runHostEvidenceRequest({
|
||||
requestId: data.requestId,
|
||||
action: data.action,
|
||||
tailLines:
|
||||
typeof data.tail_lines === 'number' ? data.tail_lines : undefined,
|
||||
});
|
||||
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId,
|
||||
...result,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sourceGroup,
|
||||
requestId: data.requestId,
|
||||
action: data.action,
|
||||
ok: result.ok,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
'Processed host evidence request via IPC',
|
||||
);
|
||||
}
|
||||
await handleHostEvidenceRequest(data, sourceGroup, isMain, deps);
|
||||
break;
|
||||
|
||||
case 'update_task':
|
||||
if (data.taskId) {
|
||||
const task = getTaskById(data.taskId);
|
||||
if (!task) {
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Task not found for update',
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (!isMain && task.group_folder !== sourceGroup) {
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, sourceGroup },
|
||||
'Unauthorized task update attempt',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
if (data.prompt !== undefined) updates.prompt = data.prompt;
|
||||
if (data.schedule_type !== undefined) {
|
||||
updates.schedule_type = data.schedule_type as
|
||||
| 'cron'
|
||||
| 'interval'
|
||||
| 'once';
|
||||
}
|
||||
if (data.schedule_value !== undefined) {
|
||||
updates.schedule_value = data.schedule_value;
|
||||
}
|
||||
|
||||
if (data.schedule_type || data.schedule_value) {
|
||||
const updatedTask = {
|
||||
...task,
|
||||
...updates,
|
||||
};
|
||||
if (updatedTask.schedule_type === 'cron') {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(
|
||||
updatedTask.schedule_value,
|
||||
{ tz: TIMEZONE },
|
||||
);
|
||||
updates.next_run = interval.next().toISOString();
|
||||
} catch {
|
||||
logger.warn(
|
||||
{ taskId: data.taskId, value: updatedTask.schedule_value },
|
||||
'Invalid cron in task update',
|
||||
);
|
||||
break;
|
||||
}
|
||||
} else if (updatedTask.schedule_type === 'interval') {
|
||||
const ms = parseInt(updatedTask.schedule_value, 10);
|
||||
if (!isNaN(ms) && ms > 0) {
|
||||
updates.next_run = new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTask(data.taskId, updates);
|
||||
logger.info(
|
||||
{ taskId: data.taskId, sourceGroup, updates },
|
||||
'Task updated via IPC',
|
||||
);
|
||||
}
|
||||
handleUpdateTask(data, sourceGroup, isMain);
|
||||
break;
|
||||
|
||||
case 'refresh_groups':
|
||||
if (isMain) {
|
||||
logger.info(
|
||||
{ sourceGroup },
|
||||
'Group metadata refresh requested via IPC',
|
||||
);
|
||||
await deps.syncGroups(true);
|
||||
const availableGroups = deps.getAvailableGroups();
|
||||
deps.writeGroupsSnapshot(sourceGroup, true, availableGroups);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ sourceGroup },
|
||||
'Unauthorized refresh_groups attempt blocked',
|
||||
);
|
||||
}
|
||||
await handleRefreshGroups(sourceGroup, isMain, deps);
|
||||
break;
|
||||
|
||||
case 'assign_room':
|
||||
if (!isMain) {
|
||||
logger.warn(
|
||||
{ sourceGroup, type: data.type },
|
||||
`Unauthorized ${data.type} attempt blocked`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (data.jid && data.name) {
|
||||
if (data.folder && !isValidGroupFolder(data.folder)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, folder: data.folder },
|
||||
`Invalid ${data.type} request - unsafe folder name`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
data.room_mode !== undefined &&
|
||||
data.room_mode !== 'single' &&
|
||||
data.room_mode !== 'tribunal'
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, roomMode: data.room_mode },
|
||||
'Invalid assign_room request - unknown room_mode',
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
data.owner_agent_type !== undefined &&
|
||||
data.owner_agent_type !== 'claude-code' &&
|
||||
data.owner_agent_type !== 'codex'
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, ownerAgentType: data.owner_agent_type },
|
||||
'Invalid assign_room request - unknown owner_agent_type',
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
data.reviewer_agent_type !== undefined &&
|
||||
data.reviewer_agent_type !== 'claude-code' &&
|
||||
data.reviewer_agent_type !== 'codex'
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, reviewerAgentType: data.reviewer_agent_type },
|
||||
'Invalid assign_room request - unknown reviewer_agent_type',
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
data.arbiter_agent_type !== undefined &&
|
||||
data.arbiter_agent_type !== null &&
|
||||
data.arbiter_agent_type !== 'claude-code' &&
|
||||
data.arbiter_agent_type !== 'codex'
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, arbiterAgentType: data.arbiter_agent_type },
|
||||
'Invalid assign_room request - unknown arbiter_agent_type',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
deps.assignRoom(data.jid, {
|
||||
name: data.name,
|
||||
roomMode: data.room_mode,
|
||||
ownerAgentType: data.owner_agent_type,
|
||||
reviewerAgentType: data.reviewer_agent_type,
|
||||
arbiterAgentType: data.arbiter_agent_type,
|
||||
folder: data.folder,
|
||||
isMain: data.isMain,
|
||||
workDir: data.workDir,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
{ data },
|
||||
`Invalid ${data.type} request - missing required fields`,
|
||||
);
|
||||
}
|
||||
handleAssignRoom(data, sourceGroup, isMain, deps);
|
||||
break;
|
||||
|
||||
case 'persist_memory': {
|
||||
if (
|
||||
data.scopeKind !== 'room' ||
|
||||
typeof data.scopeKey !== 'string' ||
|
||||
typeof data.content !== 'string'
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, data },
|
||||
'Invalid persist_memory request - missing required fields',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const expectedScopeKey = `room:${sourceGroup}`;
|
||||
if (data.scopeKey !== expectedScopeKey) {
|
||||
logger.warn(
|
||||
{ sourceGroup, scopeKey: data.scopeKey, expectedScopeKey },
|
||||
'Unauthorized persist_memory attempt blocked',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
data.source_kind !== undefined &&
|
||||
data.source_kind !== 'compact' &&
|
||||
data.source_kind !== 'explicit' &&
|
||||
data.source_kind !== 'import' &&
|
||||
data.source_kind !== 'system'
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, sourceKind: data.source_kind },
|
||||
'Invalid persist_memory request - unknown source_kind',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(data.keywords) &&
|
||||
!data.keywords.every((value) => typeof value === 'string')
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, keywords: data.keywords },
|
||||
'Invalid persist_memory request - keywords must be strings',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
rememberMemory({
|
||||
scopeKind: 'room',
|
||||
scopeKey: data.scopeKey,
|
||||
content: data.content,
|
||||
keywords: data.keywords,
|
||||
memoryKind:
|
||||
typeof data.memory_kind === 'string' ? data.memory_kind : null,
|
||||
sourceKind:
|
||||
(data.source_kind as
|
||||
| 'compact'
|
||||
| 'explicit'
|
||||
| 'import'
|
||||
| 'system'
|
||||
| undefined) ?? 'compact',
|
||||
sourceRef: typeof data.source_ref === 'string' ? data.source_ref : null,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
sourceGroup,
|
||||
scopeKey: data.scopeKey,
|
||||
sourceKind: data.source_kind ?? 'compact',
|
||||
},
|
||||
'Memory persisted via IPC',
|
||||
);
|
||||
case 'persist_memory':
|
||||
handlePersistMemory(data, sourceGroup);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
||||
|
||||
148
src/ipc-task-processor/host-evidence.ts
Normal file
148
src/ipc-task-processor/host-evidence.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
isHostEvidenceAction,
|
||||
runHostEvidenceRequest,
|
||||
writeHostEvidenceResponse,
|
||||
} from '../host-evidence.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { IpcDeps, TaskIpcPayload } from '../ipc-types.js';
|
||||
|
||||
export async function handleHostEvidenceRequest(
|
||||
data: TaskIpcPayload,
|
||||
sourceGroup: string,
|
||||
isMain: boolean,
|
||||
deps: IpcDeps,
|
||||
): Promise<void> {
|
||||
if (!data.requestId) {
|
||||
logger.warn(
|
||||
{ sourceGroup },
|
||||
'Ignoring host_evidence_request without requestId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.action === 'ejclaw_room_runtime') {
|
||||
handleRoomRuntimeReportRequest(data, sourceGroup, isMain, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isHostEvidenceAction(data.action)) {
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId,
|
||||
ok: false,
|
||||
action: 'ejclaw_service_status',
|
||||
command: '',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
error: `Unsupported host evidence action: ${String(data.action)}`,
|
||||
});
|
||||
logger.warn(
|
||||
{ sourceGroup, requestId: data.requestId, action: data.action },
|
||||
'Rejected unsupported host evidence action',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runHostEvidenceRequest({
|
||||
requestId: data.requestId,
|
||||
action: data.action,
|
||||
tailLines:
|
||||
typeof data.tail_lines === 'number' ? data.tail_lines : undefined,
|
||||
taskId: typeof data.task_id === 'string' ? data.task_id : undefined,
|
||||
minutes: typeof data.minutes === 'number' ? data.minutes : undefined,
|
||||
limit: typeof data.limit === 'number' ? data.limit : undefined,
|
||||
repo: typeof data.repo === 'string' ? data.repo : undefined,
|
||||
prNumber: typeof data.pr_number === 'number' ? data.pr_number : undefined,
|
||||
runId: typeof data.run_id === 'number' ? data.run_id : undefined,
|
||||
workflowPath:
|
||||
typeof data.workflow_path === 'string' ? data.workflow_path : undefined,
|
||||
ref: typeof data.ref === 'string' ? data.ref : undefined,
|
||||
artifactKind:
|
||||
typeof data.artifact_kind === 'string' ? data.artifact_kind : undefined,
|
||||
sourceGroup,
|
||||
isMain,
|
||||
});
|
||||
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId,
|
||||
...result,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sourceGroup,
|
||||
requestId: data.requestId,
|
||||
action: data.action,
|
||||
ok: result.ok,
|
||||
exitCode: result.exitCode,
|
||||
},
|
||||
'Processed host evidence request via IPC',
|
||||
);
|
||||
}
|
||||
|
||||
function handleRoomRuntimeReportRequest(
|
||||
data: TaskIpcPayload,
|
||||
sourceGroup: string,
|
||||
isMain: boolean,
|
||||
deps: IpcDeps,
|
||||
): void {
|
||||
if (!data.chatJid) {
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId!,
|
||||
ok: false,
|
||||
action: 'ejclaw_room_runtime',
|
||||
command: 'internal:ejclaw_room_runtime',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
error: 'Missing chatJid for ejclaw_room_runtime request',
|
||||
});
|
||||
logger.warn(
|
||||
{ sourceGroup, requestId: data.requestId },
|
||||
'Rejected ejclaw_room_runtime request without chatJid',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deps.getRoomRuntimeReport) {
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId!,
|
||||
ok: false,
|
||||
action: 'ejclaw_room_runtime',
|
||||
command: 'internal:ejclaw_room_runtime',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1,
|
||||
error: 'Room runtime reporting is not configured',
|
||||
});
|
||||
logger.warn(
|
||||
{ sourceGroup, requestId: data.requestId, chatJid: data.chatJid },
|
||||
'Rejected ejclaw_room_runtime request because runtime reporter is unavailable',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const report = deps.getRoomRuntimeReport({
|
||||
chatJid: data.chatJid,
|
||||
sourceGroup,
|
||||
isMain,
|
||||
});
|
||||
writeHostEvidenceResponse(sourceGroup, {
|
||||
requestId: data.requestId!,
|
||||
ok: true,
|
||||
action: 'ejclaw_room_runtime',
|
||||
command: 'internal:ejclaw_room_runtime',
|
||||
stdout: JSON.stringify(report, null, 2),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
sourceGroup,
|
||||
requestId: data.requestId,
|
||||
action: data.action,
|
||||
chatJid: data.chatJid,
|
||||
},
|
||||
'Processed room runtime report request via IPC',
|
||||
);
|
||||
}
|
||||
96
src/ipc-task-processor/memory.ts
Normal file
96
src/ipc-task-processor/memory.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { rememberMemory } from '../db.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { TaskIpcPayload } from '../ipc-types.js';
|
||||
|
||||
type MemorySourceKind = Parameters<typeof rememberMemory>[0]['sourceKind'];
|
||||
|
||||
export function handlePersistMemory(
|
||||
data: TaskIpcPayload,
|
||||
sourceGroup: string,
|
||||
): void {
|
||||
if (!hasPersistMemoryFields(data)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, data },
|
||||
'Invalid persist_memory request - missing required fields',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedScopeKey = `room:${sourceGroup}`;
|
||||
if (data.scopeKey !== expectedScopeKey) {
|
||||
logger.warn(
|
||||
{ sourceGroup, scopeKey: data.scopeKey, expectedScopeKey },
|
||||
'Unauthorized persist_memory attempt blocked',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMemorySourceKind(data.source_kind)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, sourceKind: data.source_kind },
|
||||
'Invalid persist_memory request - unknown source_kind',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasValidKeywords(data.keywords)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, keywords: data.keywords },
|
||||
'Invalid persist_memory request - keywords must be strings',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
rememberMemory({
|
||||
scopeKind: 'room',
|
||||
scopeKey: data.scopeKey,
|
||||
content: data.content,
|
||||
keywords: data.keywords,
|
||||
memoryKind: typeof data.memory_kind === 'string' ? data.memory_kind : null,
|
||||
sourceKind: data.source_kind ?? 'compact',
|
||||
sourceRef: typeof data.source_ref === 'string' ? data.source_ref : null,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
sourceGroup,
|
||||
scopeKey: data.scopeKey,
|
||||
sourceKind: data.source_kind ?? 'compact',
|
||||
},
|
||||
'Memory persisted via IPC',
|
||||
);
|
||||
}
|
||||
|
||||
function hasPersistMemoryFields(
|
||||
data: TaskIpcPayload,
|
||||
): data is TaskIpcPayload & {
|
||||
scopeKind: 'room';
|
||||
scopeKey: string;
|
||||
content: string;
|
||||
} {
|
||||
return (
|
||||
data.scopeKind === 'room' &&
|
||||
typeof data.scopeKey === 'string' &&
|
||||
typeof data.content === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isMemorySourceKind(
|
||||
value: string | undefined,
|
||||
): value is MemorySourceKind | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === 'compact' ||
|
||||
value === 'explicit' ||
|
||||
value === 'import' ||
|
||||
value === 'system'
|
||||
);
|
||||
}
|
||||
|
||||
function hasValidKeywords(
|
||||
value: TaskIpcPayload['keywords'],
|
||||
): value is string[] | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
||||
);
|
||||
}
|
||||
135
src/ipc-task-processor/rooms.ts
Normal file
135
src/ipc-task-processor/rooms.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { isValidGroupFolder } from '../group-folder.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { IpcDeps, TaskIpcPayload } from '../ipc-types.js';
|
||||
import type { AgentType, RoomMode } from '../types.js';
|
||||
|
||||
export async function handleRefreshGroups(
|
||||
sourceGroup: string,
|
||||
isMain: boolean,
|
||||
deps: IpcDeps,
|
||||
): Promise<void> {
|
||||
if (!isMain) {
|
||||
logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC');
|
||||
await deps.syncGroups(true);
|
||||
const availableGroups = deps.getAvailableGroups();
|
||||
deps.writeGroupsSnapshot(sourceGroup, true, availableGroups);
|
||||
}
|
||||
|
||||
export function handleAssignRoom(
|
||||
data: TaskIpcPayload,
|
||||
sourceGroup: string,
|
||||
isMain: boolean,
|
||||
deps: IpcDeps,
|
||||
): void {
|
||||
const validation = validateAssignRoomRequest(data, sourceGroup, isMain);
|
||||
if (!validation.ok) return;
|
||||
|
||||
deps.assignRoom(data.jid!, {
|
||||
name: data.name!,
|
||||
roomMode: data.room_mode,
|
||||
ownerAgentType: data.owner_agent_type,
|
||||
reviewerAgentType: data.reviewer_agent_type,
|
||||
arbiterAgentType: data.arbiter_agent_type,
|
||||
folder: data.folder,
|
||||
trigger: data.trigger,
|
||||
requiresTrigger: data.requiresTrigger,
|
||||
isMain: data.isMain,
|
||||
workDir: data.workDir,
|
||||
});
|
||||
}
|
||||
|
||||
function validateAssignRoomRequest(
|
||||
data: TaskIpcPayload,
|
||||
sourceGroup: string,
|
||||
isMain: boolean,
|
||||
): { ok: true } | { ok: false } {
|
||||
if (!isMain) {
|
||||
logger.warn(
|
||||
{ sourceGroup, type: data.type },
|
||||
`Unauthorized ${data.type} attempt blocked`,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (!data.jid || !data.name) {
|
||||
logger.warn(
|
||||
{ data },
|
||||
`Invalid ${data.type} request - missing required fields`,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (data.folder && !isValidGroupFolder(data.folder)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, folder: data.folder },
|
||||
`Invalid ${data.type} request - unsafe folder name`,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (data.trigger !== undefined && typeof data.trigger !== 'string') {
|
||||
logger.warn(
|
||||
{ sourceGroup, trigger: data.trigger },
|
||||
'Invalid assign_room request - trigger must be a string',
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (
|
||||
data.requiresTrigger !== undefined &&
|
||||
typeof data.requiresTrigger !== 'boolean'
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, requiresTrigger: data.requiresTrigger },
|
||||
'Invalid assign_room request - requiresTrigger must be a boolean',
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (data.room_mode !== undefined && !isRoomMode(data.room_mode)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, roomMode: data.room_mode },
|
||||
'Invalid assign_room request - unknown room_mode',
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (
|
||||
data.owner_agent_type !== undefined &&
|
||||
!isAssignableAgentType(data.owner_agent_type)
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, ownerAgentType: data.owner_agent_type },
|
||||
'Invalid assign_room request - unknown owner_agent_type',
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (
|
||||
data.reviewer_agent_type !== undefined &&
|
||||
!isAssignableAgentType(data.reviewer_agent_type)
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, reviewerAgentType: data.reviewer_agent_type },
|
||||
'Invalid assign_room request - unknown reviewer_agent_type',
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
if (
|
||||
data.arbiter_agent_type !== undefined &&
|
||||
data.arbiter_agent_type !== null &&
|
||||
!isAssignableAgentType(data.arbiter_agent_type)
|
||||
) {
|
||||
logger.warn(
|
||||
{ sourceGroup, arbiterAgentType: data.arbiter_agent_type },
|
||||
'Invalid assign_room request - unknown arbiter_agent_type',
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function isRoomMode(value: string): value is RoomMode {
|
||||
return value === 'single' || value === 'tribunal';
|
||||
}
|
||||
|
||||
function isAssignableAgentType(value: string): value is AgentType {
|
||||
return value === 'claude-code' || value === 'codex';
|
||||
}
|
||||
218
src/ipc-task-processor/schedule.ts
Normal file
218
src/ipc-task-processor/schedule.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { normalizeTaskContextMode } from 'ejclaw-runners-shared';
|
||||
|
||||
import { createTask, findDuplicateCiWatcher } from '../db.js';
|
||||
import { normalizeStoredAgentType } from '../db/room-registration.js';
|
||||
import { TIMEZONE } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
DEFAULT_WATCH_CI_MAX_DURATION_MS,
|
||||
isWatchCiTask,
|
||||
} from '../task-watch-status.js';
|
||||
import type { IpcDeps, TaskIpcPayload } from '../ipc-types.js';
|
||||
import { normalizePairedRoomRole, type RegisteredGroup } from '../types.js';
|
||||
|
||||
type RoomBindings = ReturnType<IpcDeps['roomBindings']>;
|
||||
type ScheduleType = 'cron' | 'interval' | 'once';
|
||||
|
||||
interface ResolvedScheduleTarget {
|
||||
folder: string;
|
||||
jid: string;
|
||||
room: RegisteredGroup;
|
||||
}
|
||||
|
||||
export function handleScheduleTask(
|
||||
data: TaskIpcPayload,
|
||||
sourceGroup: string,
|
||||
isMain: boolean,
|
||||
deps: IpcDeps,
|
||||
): void {
|
||||
if (!hasRequiredScheduleFields(data)) return;
|
||||
|
||||
const roomBindings = deps.roomBindings();
|
||||
const target = resolveScheduleTarget(data.targetJid, roomBindings);
|
||||
if (!target) return;
|
||||
|
||||
if (!isMain && target.folder !== sourceGroup) {
|
||||
logger.warn(
|
||||
{ sourceGroup, targetFolder: target.folder },
|
||||
'Unauthorized schedule_task attempt blocked',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleType = data.schedule_type as ScheduleType;
|
||||
const nextRunResult = resolveNextRun(data, scheduleType);
|
||||
if (!nextRunResult.ok) return;
|
||||
|
||||
if (isDuplicateCiWatcher(data, target.jid, sourceGroup)) return;
|
||||
|
||||
const taskId =
|
||||
data.taskId ||
|
||||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const contextMode = normalizeTaskContextMode(data.context_mode);
|
||||
const scheduledAgentType =
|
||||
normalizeStoredAgentType(data.agent_type) ??
|
||||
target.room.agentType ??
|
||||
'claude-code';
|
||||
const scheduledRoomRole = normalizePairedRoomRole(data.room_role);
|
||||
|
||||
createTask({
|
||||
id: taskId,
|
||||
group_folder: target.folder,
|
||||
chat_jid: target.jid,
|
||||
agent_type: scheduledAgentType,
|
||||
room_role: scheduledRoomRole ?? null,
|
||||
ci_provider: data.ci_provider ?? null,
|
||||
ci_metadata: data.ci_metadata ?? null,
|
||||
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
|
||||
? DEFAULT_WATCH_CI_MAX_DURATION_MS
|
||||
: null,
|
||||
prompt: data.prompt,
|
||||
schedule_type: scheduleType,
|
||||
schedule_value: data.schedule_value,
|
||||
context_mode: contextMode,
|
||||
next_run: nextRunResult.nextRun,
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
taskId,
|
||||
sourceGroup,
|
||||
targetFolder: target.folder,
|
||||
contextMode,
|
||||
agentType: scheduledAgentType,
|
||||
roomRole: scheduledRoomRole ?? null,
|
||||
},
|
||||
'Task created via IPC',
|
||||
);
|
||||
if (
|
||||
nextRunResult.nextRun &&
|
||||
new Date(nextRunResult.nextRun).getTime() <= Date.now()
|
||||
) {
|
||||
deps.nudgeScheduler?.();
|
||||
}
|
||||
}
|
||||
|
||||
function hasRequiredScheduleFields(
|
||||
data: TaskIpcPayload,
|
||||
): data is TaskIpcPayload & {
|
||||
prompt: string;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
targetJid: string;
|
||||
} {
|
||||
return Boolean(
|
||||
data.prompt && data.schedule_type && data.schedule_value && data.targetJid,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveScheduleTarget(
|
||||
targetJid: string,
|
||||
roomBindings: RoomBindings,
|
||||
): ResolvedScheduleTarget | null {
|
||||
const targetRoom =
|
||||
roomBindings[targetJid] ||
|
||||
Object.values(roomBindings).find((group) => group.folder === targetJid);
|
||||
|
||||
if (!targetRoom) {
|
||||
logger.warn(
|
||||
{ targetJid },
|
||||
'Cannot schedule task: target group not registered',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedTargetJid =
|
||||
roomBindings[targetJid] !== undefined
|
||||
? targetJid
|
||||
: Object.entries(roomBindings).find(
|
||||
([, group]) => group.folder === targetRoom.folder,
|
||||
)?.[0];
|
||||
|
||||
if (!resolvedTargetJid) {
|
||||
logger.warn(
|
||||
{ targetJid, targetFolder: targetRoom.folder },
|
||||
'Cannot resolve scheduled task target JID from folder',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
folder: targetRoom.folder,
|
||||
jid: resolvedTargetJid,
|
||||
room: targetRoom,
|
||||
};
|
||||
}
|
||||
|
||||
function isDuplicateCiWatcher(
|
||||
data: TaskIpcPayload,
|
||||
targetJid: string,
|
||||
sourceGroup: string,
|
||||
): boolean {
|
||||
if (!data.ci_provider || !data.ci_metadata) return false;
|
||||
|
||||
const existing = findDuplicateCiWatcher(
|
||||
targetJid,
|
||||
data.ci_provider,
|
||||
data.ci_metadata,
|
||||
);
|
||||
if (!existing) return false;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
existingTaskId: existing.id,
|
||||
existingAgentType: existing.agent_type,
|
||||
ciProvider: data.ci_provider,
|
||||
sourceGroup,
|
||||
},
|
||||
'Duplicate CI watcher skipped — another agent already watches this run',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveNextRun(
|
||||
data: TaskIpcPayload & { prompt?: string; schedule_value?: string },
|
||||
scheduleType: ScheduleType,
|
||||
): { ok: true; nextRun: string | null } | { ok: false } {
|
||||
if (scheduleType === 'cron') {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(data.schedule_value!, {
|
||||
tz: TIMEZONE,
|
||||
});
|
||||
return { ok: true, nextRun: interval.next().toISOString() };
|
||||
} catch {
|
||||
logger.warn(
|
||||
{ scheduleValue: data.schedule_value },
|
||||
'Invalid cron expression',
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleType === 'interval') {
|
||||
const ms = parseInt(data.schedule_value!, 10);
|
||||
if (isNaN(ms) || ms <= 0) {
|
||||
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval');
|
||||
return { ok: false };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
nextRun: isWatchCiTask({ prompt: data.prompt! })
|
||||
? new Date().toISOString()
|
||||
: new Date(Date.now() + ms).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (scheduleType === 'once') {
|
||||
const date = new Date(data.schedule_value!);
|
||||
if (isNaN(date.getTime())) {
|
||||
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp');
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, nextRun: date.toISOString() };
|
||||
}
|
||||
|
||||
return { ok: true, nextRun: null };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user