build: unify bun quality gate
This commit is contained in:
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -12,14 +12,10 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
- uses: oven-sh/setup-bun@v2
|
||||||
- run: npm ci
|
with:
|
||||||
|
bun-version: 1.3.11
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Format check
|
- name: Check
|
||||||
run: npm run format:check
|
run: bun run check
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: npx vitest run
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
npm run format:fix
|
bun run format:fix
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -7,15 +7,19 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"build:runners": "bun install --cwd runners/agent-runner && bun run --cwd runners/agent-runner build && bun install --cwd runners/codex-runner && bun run --cwd runners/codex-runner build",
|
"install:runners": "bun install --frozen-lockfile --cwd runners/shared && bun install --frozen-lockfile --cwd runners/agent-runner && bun install --frozen-lockfile --cwd runners/codex-runner",
|
||||||
"build:runtime": "bun run build && bun run build:runners",
|
"build:runners": "bun run install:runners && bun run --cwd runners/shared build && bun run --cwd runners/agent-runner build && bun run --cwd runners/codex-runner build",
|
||||||
"deploy": "git pull --ff-only && bun run build:runtime && systemctl --user restart ejclaw",
|
"build:all": "bun run build && bun run build:runners",
|
||||||
|
"build:runtime": "bun run build:all",
|
||||||
|
"deploy": "git pull --ff-only && bun run build:all && systemctl --user restart ejclaw",
|
||||||
"start": "bun dist/index.js",
|
"start": "bun dist/index.js",
|
||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format:fix": "prettier --write \"src/**/*.ts\"",
|
"typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json",
|
||||||
"format:check": "prettier --check \"src/**/*.ts\"",
|
"format:fix": "prettier --write \"{src,setup,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
|
||||||
|
"format:check": "prettier --check \"{src,setup,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
|
||||||
|
"check": "bun run format:check && bun run typecheck:all && bun run test && bun run build:all",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"setup": "bun setup/index.ts"
|
"setup": "bun setup/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,9 +58,7 @@ export async function waitForHostEvidenceResponse(
|
|||||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(`Timed out waiting for host evidence response: ${requestId}`);
|
||||||
`Timed out waiting for host evidence response: ${requestId}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHostEvidenceResponse(
|
export function formatHostEvidenceResponse(
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
import {
|
||||||
|
query,
|
||||||
|
HookCallback,
|
||||||
|
PreCompactHookInput,
|
||||||
|
PreToolUseHookInput,
|
||||||
|
} from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -76,7 +81,14 @@ interface SessionsIndex {
|
|||||||
|
|
||||||
type ContentBlock =
|
type ContentBlock =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
| { type: 'image'; source: { type: 'base64'; media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; data: string } };
|
| {
|
||||||
|
type: 'image';
|
||||||
|
source: {
|
||||||
|
type: 'base64';
|
||||||
|
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface SDKUserMessage {
|
interface SDKUserMessage {
|
||||||
type: 'user';
|
type: 'user';
|
||||||
@@ -106,8 +118,11 @@ const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
|||||||
/** SSOT: src/agent-protocol.ts — keep in sync */
|
/** SSOT: src/agent-protocol.ts — keep in sync */
|
||||||
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
'.jpg': 'image/jpeg',
|
||||||
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,11 +153,20 @@ function buildMultimodalContent(text: string): string | ContentBlock[] {
|
|||||||
}
|
}
|
||||||
const data = fs.readFileSync(imgPath).toString('base64');
|
const data = fs.readFileSync(imgPath).toString('base64');
|
||||||
const ext = path.extname(imgPath).toLowerCase();
|
const ext = path.extname(imgPath).toLowerCase();
|
||||||
const mediaType = (MIME_TYPES[ext] || 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
const mediaType = (MIME_TYPES[ext] || 'image/png') as
|
||||||
blocks.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data } });
|
| 'image/jpeg'
|
||||||
|
| 'image/png'
|
||||||
|
| 'image/gif'
|
||||||
|
| 'image/webp';
|
||||||
|
blocks.push({
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: mediaType, data },
|
||||||
|
});
|
||||||
log(`Added image block: ${imgPath} (${mediaType})`);
|
log(`Added image block: ${imgPath} (${mediaType})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`);
|
log(
|
||||||
|
`Failed to read image ${imgPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +204,9 @@ class MessageStream {
|
|||||||
yield this.queue.shift()!;
|
yield this.queue.shift()!;
|
||||||
}
|
}
|
||||||
if (this.done) return;
|
if (this.done) return;
|
||||||
await new Promise<void>(r => { this.waiting = r; });
|
await new Promise<void>((r) => {
|
||||||
|
this.waiting = r;
|
||||||
|
});
|
||||||
this.waiting = null;
|
this.waiting = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +216,9 @@ async function readStdin(): Promise<string> {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
process.stdin.on('data', chunk => { data += chunk; });
|
process.stdin.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
process.stdin.on('end', () => resolve(data));
|
process.stdin.on('end', () => resolve(data));
|
||||||
process.stdin.on('error', reject);
|
process.stdin.on('error', reject);
|
||||||
});
|
});
|
||||||
@@ -246,7 +274,10 @@ function extractAssistantText(message: unknown): string | null {
|
|||||||
return text || null;
|
return text || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
|
function getSessionSummary(
|
||||||
|
sessionId: string,
|
||||||
|
transcriptPath: string,
|
||||||
|
): string | null {
|
||||||
const projectDir = path.dirname(transcriptPath);
|
const projectDir = path.dirname(transcriptPath);
|
||||||
const indexPath = path.join(projectDir, 'sessions-index.json');
|
const indexPath = path.join(projectDir, 'sessions-index.json');
|
||||||
|
|
||||||
@@ -256,13 +287,17 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
const index: SessionsIndex = JSON.parse(
|
||||||
const entry = index.entries.find(e => e.sessionId === sessionId);
|
fs.readFileSync(indexPath, 'utf-8'),
|
||||||
|
);
|
||||||
|
const entry = index.entries.find((e) => e.sessionId === sessionId);
|
||||||
if (entry?.summary) {
|
if (entry?.summary) {
|
||||||
return entry.summary;
|
return entry.summary;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
|
log(
|
||||||
|
`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -283,7 +318,10 @@ function writeHostTaskIpcFile(data: object): string {
|
|||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
|
async function persistCompactMemory(
|
||||||
|
summary: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
const normalized = summary.trim();
|
const normalized = summary.trim();
|
||||||
if (!normalized || !GROUP_FOLDER) return;
|
if (!normalized || !GROUP_FOLDER) return;
|
||||||
|
|
||||||
@@ -359,7 +397,11 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
|||||||
const filename = `${date}-${name}.md`;
|
const filename = `${date}-${name}.md`;
|
||||||
const filePath = path.join(conversationsDir, filename);
|
const filePath = path.join(conversationsDir, filename);
|
||||||
|
|
||||||
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
|
const markdown = formatTranscriptMarkdown(
|
||||||
|
messages,
|
||||||
|
summary,
|
||||||
|
assistantName,
|
||||||
|
);
|
||||||
fs.writeFileSync(filePath, markdown);
|
fs.writeFileSync(filePath, markdown);
|
||||||
|
|
||||||
log(`Archived conversation to ${filePath}`);
|
log(`Archived conversation to ${filePath}`);
|
||||||
@@ -368,7 +410,9 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
|||||||
await persistCompactMemory(summary, sessionId);
|
await persistCompactMemory(summary, sessionId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
log(
|
||||||
|
`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@@ -444,9 +488,12 @@ function parseTranscript(content: string): ParsedMessage[] {
|
|||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line);
|
const entry = JSON.parse(line);
|
||||||
if (entry.type === 'user' && entry.message?.content) {
|
if (entry.type === 'user' && entry.message?.content) {
|
||||||
const text = typeof entry.message.content === 'string'
|
const text =
|
||||||
? entry.message.content
|
typeof entry.message.content === 'string'
|
||||||
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
|
? entry.message.content
|
||||||
|
: entry.message.content
|
||||||
|
.map((c: { text?: string }) => c.text || '')
|
||||||
|
.join('');
|
||||||
if (text) messages.push({ role: 'user', content: text });
|
if (text) messages.push({ role: 'user', content: text });
|
||||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||||
const textParts = entry.message.content
|
const textParts = entry.message.content
|
||||||
@@ -455,22 +502,26 @@ function parseTranscript(content: string): ParsedMessage[] {
|
|||||||
const text = textParts.join('');
|
const text = textParts.join('');
|
||||||
if (text) messages.push({ role: 'assistant', content: text });
|
if (text) messages.push({ role: 'assistant', content: text });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
|
function formatTranscriptMarkdown(
|
||||||
|
messages: ParsedMessage[],
|
||||||
|
title?: string | null,
|
||||||
|
assistantName?: string,
|
||||||
|
): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
|
const formatDateTime = (d: Date) =>
|
||||||
month: 'short',
|
d.toLocaleString('en-US', {
|
||||||
day: 'numeric',
|
month: 'short',
|
||||||
hour: 'numeric',
|
day: 'numeric',
|
||||||
minute: '2-digit',
|
hour: 'numeric',
|
||||||
hour12: true
|
minute: '2-digit',
|
||||||
});
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`# ${title || 'Conversation'}`);
|
lines.push(`# ${title || 'Conversation'}`);
|
||||||
@@ -481,10 +532,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
|
const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant';
|
||||||
const content = msg.content.length > 2000
|
const content =
|
||||||
? msg.content.slice(0, 2000) + '...'
|
msg.content.length > 2000
|
||||||
: msg.content;
|
? msg.content.slice(0, 2000) + '...'
|
||||||
|
: msg.content;
|
||||||
lines.push(`**${sender}**: ${content}`);
|
lines.push(`**${sender}**: ${content}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
@@ -497,7 +549,11 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
|
|||||||
*/
|
*/
|
||||||
function shouldClose(): boolean {
|
function shouldClose(): boolean {
|
||||||
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
|
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
|
||||||
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
try {
|
||||||
|
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -510,8 +566,9 @@ function shouldClose(): boolean {
|
|||||||
function drainIpcInput(): string[] {
|
function drainIpcInput(): string[] {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||||
const files = fs.readdirSync(IPC_INPUT_DIR)
|
const files = fs
|
||||||
.filter(f => f.endsWith('.json'))
|
.readdirSync(IPC_INPUT_DIR)
|
||||||
|
.filter((f) => f.endsWith('.json'))
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
@@ -524,8 +581,14 @@ function drainIpcInput(): string[] {
|
|||||||
messages.push(data.text);
|
messages.push(data.text);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
log(
|
||||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return messages;
|
return messages;
|
||||||
@@ -568,7 +631,9 @@ async function runQuery(
|
|||||||
// Flush any buffered text before closing — the for-await loop may not
|
// Flush any buffered text before closing — the for-await loop may not
|
||||||
// reach the post-loop flush code after stream.end().
|
// reach the post-loop flush code after stream.end().
|
||||||
if (pendingProgressText && !terminalResultObserved) {
|
if (pendingProgressText && !terminalResultObserved) {
|
||||||
log(`Flushing pending text before close (${pendingProgressText.length} chars)`);
|
log(
|
||||||
|
`Flushing pending text before close (${pendingProgressText.length} chars)`,
|
||||||
|
);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
...normalizeStructuredOutput(pendingProgressText),
|
...normalizeStructuredOutput(pendingProgressText),
|
||||||
@@ -605,7 +670,9 @@ async function runQuery(
|
|||||||
const effectiveCwd = WORK_DIR || GROUP_DIR;
|
const effectiveCwd = WORK_DIR || GROUP_DIR;
|
||||||
if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
|
if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
|
||||||
extraDirs.push(GROUP_DIR);
|
extraDirs.push(GROUP_DIR);
|
||||||
log(`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`);
|
log(
|
||||||
|
`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (extraDirs.length > 0) {
|
if (extraDirs.length > 0) {
|
||||||
log(`Additional directories: ${extraDirs.join(', ')}`);
|
log(`Additional directories: ${extraDirs.join(', ')}`);
|
||||||
@@ -617,11 +684,17 @@ async function runQuery(
|
|||||||
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
|
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
|
||||||
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
|
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
const effort = (process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') || undefined;
|
const effort =
|
||||||
const thinking = thinkingType === 'adaptive' ? { type: 'adaptive' as const }
|
(process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') ||
|
||||||
: thinkingType === 'enabled' ? { type: 'enabled' as const, budgetTokens: thinkingBudget }
|
undefined;
|
||||||
: thinkingType === 'disabled' ? { type: 'disabled' as const }
|
const thinking =
|
||||||
: undefined;
|
thinkingType === 'adaptive'
|
||||||
|
? { type: 'adaptive' as const }
|
||||||
|
: thinkingType === 'enabled'
|
||||||
|
? { type: 'enabled' as const, budgetTokens: thinkingBudget }
|
||||||
|
: thinkingType === 'disabled'
|
||||||
|
? { type: 'disabled' as const }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (model) log(`Using model: ${model}`);
|
if (model) log(`Using model: ${model}`);
|
||||||
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
|
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
|
||||||
@@ -643,22 +716,42 @@ async function runQuery(
|
|||||||
const allowedTools = readonlyReviewerRuntime
|
const allowedTools = readonlyReviewerRuntime
|
||||||
? [
|
? [
|
||||||
'Bash',
|
'Bash',
|
||||||
'Read', 'Glob', 'Grep',
|
'Read',
|
||||||
'WebSearch', 'WebFetch',
|
'Glob',
|
||||||
'Task', 'TaskOutput', 'TaskStop',
|
'Grep',
|
||||||
'TeamCreate', 'TeamDelete', 'SendMessage',
|
'WebSearch',
|
||||||
'TodoWrite', 'ToolSearch', 'Skill',
|
'WebFetch',
|
||||||
|
'Task',
|
||||||
|
'TaskOutput',
|
||||||
|
'TaskStop',
|
||||||
|
'TeamCreate',
|
||||||
|
'TeamDelete',
|
||||||
|
'SendMessage',
|
||||||
|
'TodoWrite',
|
||||||
|
'ToolSearch',
|
||||||
|
'Skill',
|
||||||
'mcp__ejclaw__*',
|
'mcp__ejclaw__*',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'Bash',
|
'Bash',
|
||||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
'Read',
|
||||||
'WebSearch', 'WebFetch',
|
'Write',
|
||||||
'Task', 'TaskOutput', 'TaskStop',
|
'Edit',
|
||||||
'TeamCreate', 'TeamDelete', 'SendMessage',
|
'Glob',
|
||||||
'TodoWrite', 'ToolSearch', 'Skill',
|
'Grep',
|
||||||
|
'WebSearch',
|
||||||
|
'WebFetch',
|
||||||
|
'Task',
|
||||||
|
'TaskOutput',
|
||||||
|
'TaskStop',
|
||||||
|
'TeamCreate',
|
||||||
|
'TeamDelete',
|
||||||
|
'SendMessage',
|
||||||
|
'TodoWrite',
|
||||||
|
'ToolSearch',
|
||||||
|
'Skill',
|
||||||
'NotebookEdit',
|
'NotebookEdit',
|
||||||
'mcp__ejclaw__*'
|
'mcp__ejclaw__*',
|
||||||
];
|
];
|
||||||
|
|
||||||
const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter(
|
const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter(
|
||||||
@@ -701,8 +794,7 @@ async function runQuery(
|
|||||||
EJCLAW_CHAT_JID: runnerInput.chatJid,
|
EJCLAW_CHAT_JID: runnerInput.chatJid,
|
||||||
EJCLAW_GROUP_FOLDER: runnerInput.groupFolder,
|
EJCLAW_GROUP_FOLDER: runnerInput.groupFolder,
|
||||||
EJCLAW_IS_MAIN: runnerInput.isMain ? '1' : '0',
|
EJCLAW_IS_MAIN: runnerInput.isMain ? '1' : '0',
|
||||||
EJCLAW_AGENT_TYPE:
|
EJCLAW_AGENT_TYPE: process.env.EJCLAW_AGENT_TYPE || 'claude-code',
|
||||||
process.env.EJCLAW_AGENT_TYPE || 'claude-code',
|
|
||||||
EJCLAW_ROOM_ROLE: runnerInput.roomRoleContext?.role || '',
|
EJCLAW_ROOM_ROLE: runnerInput.roomRoleContext?.role || '',
|
||||||
...(process.env.EJCLAW_IPC_DIR && {
|
...(process.env.EJCLAW_IPC_DIR && {
|
||||||
EJCLAW_IPC_DIR: process.env.EJCLAW_IPC_DIR,
|
EJCLAW_IPC_DIR: process.env.EJCLAW_IPC_DIR,
|
||||||
@@ -714,7 +806,9 @@ async function runQuery(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(runnerInput.assistantName)] }],
|
PreCompact: [
|
||||||
|
{ hooks: [createPreCompactHook(runnerInput.assistantName)] },
|
||||||
|
],
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{
|
{
|
||||||
matcher: 'Bash',
|
matcher: 'Bash',
|
||||||
@@ -725,10 +819,13 @@ async function runQuery(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
agentProgressSummaries: true,
|
agentProgressSummaries: true,
|
||||||
}
|
},
|
||||||
})) {
|
})) {
|
||||||
messageCount++;
|
messageCount++;
|
||||||
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
|
const msgType =
|
||||||
|
message.type === 'system'
|
||||||
|
? `system/${(message as { subtype?: string }).subtype}`
|
||||||
|
: message.type;
|
||||||
log(`[msg #${messageCount}] type=${msgType}`);
|
log(`[msg #${messageCount}] type=${msgType}`);
|
||||||
|
|
||||||
// Flush pending intermediate text as a regular message on non-assistant events.
|
// Flush pending intermediate text as a regular message on non-assistant events.
|
||||||
@@ -747,15 +844,37 @@ async function runQuery(
|
|||||||
log(`Session initialized: ${newSessionId}`);
|
log(`Session initialized: ${newSessionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
if (
|
||||||
const meta = (message as { compact_metadata?: { trigger?: string; pre_tokens?: number } }).compact_metadata;
|
message.type === 'system' &&
|
||||||
log(`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`);
|
(message as { subtype?: string }).subtype === 'compact_boundary'
|
||||||
|
) {
|
||||||
|
const meta = (
|
||||||
|
message as {
|
||||||
|
compact_metadata?: { trigger?: string; pre_tokens?: number };
|
||||||
|
}
|
||||||
|
).compact_metadata;
|
||||||
|
log(
|
||||||
|
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
if (
|
||||||
const tn = message as { task_id: string; status: string; summary: string };
|
message.type === 'system' &&
|
||||||
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
|
(message as { subtype?: string }).subtype === 'task_notification'
|
||||||
if (tn.status === 'completed' || tn.status === 'error' || tn.status === 'cancelled') {
|
) {
|
||||||
|
const tn = message as {
|
||||||
|
task_id: string;
|
||||||
|
status: string;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
log(
|
||||||
|
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
tn.status === 'completed' ||
|
||||||
|
tn.status === 'error' ||
|
||||||
|
tn.status === 'cancelled'
|
||||||
|
) {
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'progress',
|
phase: 'progress',
|
||||||
@@ -767,11 +886,15 @@ async function runQuery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_progress') {
|
if (
|
||||||
|
message.type === 'system' &&
|
||||||
|
(message as { subtype?: string }).subtype === 'task_progress'
|
||||||
|
) {
|
||||||
const tp = message as Record<string, unknown>;
|
const tp = message as Record<string, unknown>;
|
||||||
const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined;
|
const taskId = typeof tp.task_id === 'string' ? tp.task_id : undefined;
|
||||||
const summary = typeof tp.summary === 'string' ? tp.summary : '';
|
const summary = typeof tp.summary === 'string' ? tp.summary : '';
|
||||||
const description = typeof tp.description === 'string' ? tp.description : '';
|
const description =
|
||||||
|
typeof tp.description === 'string' ? tp.description : '';
|
||||||
if (description && description.length <= 80) {
|
if (description && description.length <= 80) {
|
||||||
// Short tool description → show as sub-line in progress
|
// Short tool description → show as sub-line in progress
|
||||||
const normalized = normalizeStructuredOutput(description);
|
const normalized = normalizeStructuredOutput(description);
|
||||||
@@ -784,11 +907,16 @@ async function runQuery(
|
|||||||
});
|
});
|
||||||
} else if (description) {
|
} else if (description) {
|
||||||
// Long AI summary → skip (too long for progress sub-line)
|
// Long AI summary → skip (too long for progress sub-line)
|
||||||
log(`Skipping long task_progress description (${description.length} chars)`);
|
log(
|
||||||
|
`Skipping long task_progress description (${description.length} chars)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_started') {
|
if (
|
||||||
|
message.type === 'system' &&
|
||||||
|
(message as { subtype?: string }).subtype === 'task_started'
|
||||||
|
) {
|
||||||
const ts = message as { task_id: string; description?: string };
|
const ts = message as { task_id: string; description?: string };
|
||||||
const desc = ts.description || '';
|
const desc = ts.description || '';
|
||||||
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
|
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
|
||||||
@@ -835,28 +963,44 @@ async function runQuery(
|
|||||||
|
|
||||||
if (message.type === 'result') {
|
if (message.type === 'result') {
|
||||||
resultCount++;
|
resultCount++;
|
||||||
let textResult = 'result' in message ? (message as { result?: string }).result : null;
|
let textResult =
|
||||||
|
'result' in message ? (message as { result?: string }).result : null;
|
||||||
const isError = message.subtype?.startsWith('error');
|
const isError = message.subtype?.startsWith('error');
|
||||||
// Discard pending progress if it matches the final result (prevent duplicate)
|
// Discard pending progress if it matches the final result (prevent duplicate)
|
||||||
if (pendingProgressText && textResult && pendingProgressText === textResult) {
|
if (
|
||||||
|
pendingProgressText &&
|
||||||
|
textResult &&
|
||||||
|
pendingProgressText === textResult
|
||||||
|
) {
|
||||||
log(`Discarding pending progress (matches result)`);
|
log(`Discarding pending progress (matches result)`);
|
||||||
pendingProgressText = null;
|
pendingProgressText = null;
|
||||||
} else if (pendingProgressText) {
|
} else if (pendingProgressText) {
|
||||||
// If the result has no text, promote pending progress to the result
|
// If the result has no text, promote pending progress to the result
|
||||||
// so it gets delivered as the final output instead of being lost.
|
// so it gets delivered as the final output instead of being lost.
|
||||||
if (!textResult) {
|
if (!textResult) {
|
||||||
log(`Promoting pending progress text to result (${pendingProgressText.length} chars)`);
|
log(
|
||||||
|
`Promoting pending progress text to result (${pendingProgressText.length} chars)`,
|
||||||
|
);
|
||||||
textResult = pendingProgressText;
|
textResult = pendingProgressText;
|
||||||
} else {
|
} else {
|
||||||
writeOutput({ status: 'success', phase: 'intermediate', result: pendingProgressText, newSessionId });
|
writeOutput({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'intermediate',
|
||||||
|
result: pendingProgressText,
|
||||||
|
newSessionId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
pendingProgressText = null;
|
pendingProgressText = null;
|
||||||
}
|
}
|
||||||
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
log(
|
||||||
|
`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
|
||||||
|
);
|
||||||
if (isError) {
|
if (isError) {
|
||||||
// Log full error details for debugging
|
// Log full error details for debugging
|
||||||
const msg = message as Record<string, unknown>;
|
const msg = message as Record<string, unknown>;
|
||||||
const sdkErrors = Array.isArray(msg.errors) ? msg.errors as string[] : [];
|
const sdkErrors = Array.isArray(msg.errors)
|
||||||
|
? (msg.errors as string[])
|
||||||
|
: [];
|
||||||
const errorDetail = JSON.stringify({
|
const errorDetail = JSON.stringify({
|
||||||
subtype: message.subtype,
|
subtype: message.subtype,
|
||||||
result: textResult?.slice(0, 500),
|
result: textResult?.slice(0, 500),
|
||||||
@@ -868,7 +1012,8 @@ async function runQuery(
|
|||||||
});
|
});
|
||||||
log(`Error result detail: ${errorDetail}`);
|
log(`Error result detail: ${errorDetail}`);
|
||||||
// Pass SDK errors through so host can detect session issues
|
// Pass SDK errors through so host can detect session issues
|
||||||
const errorText = sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined;
|
const errorText =
|
||||||
|
sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined;
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: textResult || null,
|
result: textResult || null,
|
||||||
@@ -880,7 +1025,7 @@ async function runQuery(
|
|||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
...normalized,
|
...normalized,
|
||||||
newSessionId
|
newSessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,7 +1044,9 @@ async function runQuery(
|
|||||||
const textResult = extractAssistantText(message);
|
const textResult = extractAssistantText(message);
|
||||||
// Only log when there's something interesting (text or terminal)
|
// Only log when there's something interesting (text or terminal)
|
||||||
if (textResult || stopReason === 'end_turn') {
|
if (textResult || stopReason === 'end_turn') {
|
||||||
log(`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`);
|
log(
|
||||||
|
`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (stopReason === 'end_turn' && textResult) {
|
if (stopReason === 'end_turn' && textResult) {
|
||||||
resultCount++;
|
resultCount++;
|
||||||
@@ -932,7 +1079,9 @@ async function runQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
pendingProgressText = textResult;
|
pendingProgressText = textResult;
|
||||||
log(`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`);
|
log(
|
||||||
|
`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -940,7 +1089,9 @@ async function runQuery(
|
|||||||
// Flush any remaining buffered text that was never followed by a result event.
|
// Flush any remaining buffered text that was never followed by a result event.
|
||||||
// This happens when the agent produces a short response without a formal end_turn.
|
// This happens when the agent produces a short response without a formal end_turn.
|
||||||
if (pendingProgressText && !terminalResultObserved) {
|
if (pendingProgressText && !terminalResultObserved) {
|
||||||
log(`Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`);
|
log(
|
||||||
|
`Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`,
|
||||||
|
);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
...normalizeStructuredOutput(pendingProgressText),
|
...normalizeStructuredOutput(pendingProgressText),
|
||||||
@@ -964,13 +1115,17 @@ async function main(): Promise<void> {
|
|||||||
const stdinData = await readStdin();
|
const stdinData = await readStdin();
|
||||||
runnerInput = JSON.parse(stdinData);
|
runnerInput = JSON.parse(stdinData);
|
||||||
// Delete the temp file the entrypoint wrote — it contains secrets
|
// Delete the temp file the entrypoint wrote — it contains secrets
|
||||||
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
try {
|
||||||
|
fs.unlinkSync('/tmp/input.json');
|
||||||
|
} catch {
|
||||||
|
/* may not exist */
|
||||||
|
}
|
||||||
log(`Received input for group: ${runnerInput.groupFolder}`);
|
log(`Received input for group: ${runnerInput.groupFolder}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
|
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
});
|
});
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -984,8 +1139,9 @@ async function main(): Promise<void> {
|
|||||||
const reviewerRuntime =
|
const reviewerRuntime =
|
||||||
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
|
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
|
||||||
isReviewerRuntime(runnerInput.roomRoleContext);
|
isReviewerRuntime(runnerInput.roomRoleContext);
|
||||||
const claudeReadonlyReviewerRuntime =
|
const claudeReadonlyReviewerRuntime = isClaudeReadonlyReviewerRuntime(
|
||||||
isClaudeReadonlyReviewerRuntime(runnerInput.roomRoleContext);
|
runnerInput.roomRoleContext,
|
||||||
|
);
|
||||||
const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime
|
const claudeReadonlySandboxMode = claudeReadonlyReviewerRuntime
|
||||||
? getClaudeReadonlySandboxMode()
|
? getClaudeReadonlySandboxMode()
|
||||||
: null;
|
: null;
|
||||||
@@ -1006,7 +1162,11 @@ async function main(): Promise<void> {
|
|||||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
// Clean up stale _close sentinel from previous runner sessions
|
// Clean up stale _close sentinel from previous runner sessions
|
||||||
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
try {
|
||||||
|
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
// Effective working directory (WORK_DIR overrides GROUP_DIR)
|
// Effective working directory (WORK_DIR overrides GROUP_DIR)
|
||||||
const mainEffectiveCwd = WORK_DIR || GROUP_DIR;
|
const mainEffectiveCwd = WORK_DIR || GROUP_DIR;
|
||||||
@@ -1024,7 +1184,9 @@ async function main(): Promise<void> {
|
|||||||
// --- Slash command handling ---
|
// --- Slash command handling ---
|
||||||
// Check BEFORE prepending room role header so /compact isn't masked.
|
// Check BEFORE prepending room role header so /compact isn't masked.
|
||||||
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
|
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
|
||||||
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(runnerInput.prompt.trim());
|
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(
|
||||||
|
runnerInput.prompt.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
if (!isSessionSlashCommand) {
|
if (!isSessionSlashCommand) {
|
||||||
prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext);
|
prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext);
|
||||||
@@ -1052,13 +1214,16 @@ async function main(): Promise<void> {
|
|||||||
settingSources: ['project', 'user'] as const,
|
settingSources: ['project', 'user'] as const,
|
||||||
abortController: agentAbortController,
|
abortController: agentAbortController,
|
||||||
hooks: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(runnerInput.assistantName)] }],
|
PreCompact: [
|
||||||
|
{ hooks: [createPreCompactHook(runnerInput.assistantName)] },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})) {
|
})) {
|
||||||
const msgType = message.type === 'system'
|
const msgType =
|
||||||
? `system/${(message as { subtype?: string }).subtype}`
|
message.type === 'system'
|
||||||
: message.type;
|
? `system/${(message as { subtype?: string }).subtype}`
|
||||||
|
: message.type;
|
||||||
log(`[slash-cmd] type=${msgType}`);
|
log(`[slash-cmd] type=${msgType}`);
|
||||||
|
|
||||||
if (message.type === 'system' && message.subtype === 'init') {
|
if (message.type === 'system' && message.subtype === 'init') {
|
||||||
@@ -1067,15 +1232,27 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Observe compact_boundary to confirm compaction completed
|
// Observe compact_boundary to confirm compaction completed
|
||||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
if (
|
||||||
|
message.type === 'system' &&
|
||||||
|
(message as { subtype?: string }).subtype === 'compact_boundary'
|
||||||
|
) {
|
||||||
compactBoundarySeen = true;
|
compactBoundarySeen = true;
|
||||||
const meta = (message as { compact_metadata?: { trigger?: string; pre_tokens?: number } }).compact_metadata;
|
const meta = (
|
||||||
log(`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`);
|
message as {
|
||||||
|
compact_metadata?: { trigger?: string; pre_tokens?: number };
|
||||||
|
}
|
||||||
|
).compact_metadata;
|
||||||
|
log(
|
||||||
|
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'result') {
|
if (message.type === 'result') {
|
||||||
const resultSubtype = (message as { subtype?: string }).subtype;
|
const resultSubtype = (message as { subtype?: string }).subtype;
|
||||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
const textResult =
|
||||||
|
'result' in message
|
||||||
|
? (message as { result?: string }).result
|
||||||
|
: null;
|
||||||
|
|
||||||
if (resultSubtype?.startsWith('error')) {
|
if (resultSubtype?.startsWith('error')) {
|
||||||
hadError = true;
|
hadError = true;
|
||||||
@@ -1102,11 +1279,15 @@ async function main(): Promise<void> {
|
|||||||
writeOutput({ status: 'error', result: null, error: errorMsg });
|
writeOutput({ status: 'error', result: null, error: errorMsg });
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
|
log(
|
||||||
|
`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Warn if compact_boundary was never observed — compaction may not have occurred
|
// Warn if compact_boundary was never observed — compaction may not have occurred
|
||||||
if (!hadError && !compactBoundarySeen) {
|
if (!hadError && !compactBoundarySeen) {
|
||||||
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
|
log(
|
||||||
|
'WARNING: compact_boundary was not observed. Compaction may not have completed.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only emit final session marker if no result was emitted yet and no error occurred
|
// Only emit final session marker if no result was emitted yet and no error occurred
|
||||||
@@ -1120,7 +1301,11 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
} else if (!hadError) {
|
} else if (!hadError) {
|
||||||
// Emit session-only marker so host updates session tracking
|
// Emit session-only marker so host updates session tracking
|
||||||
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
|
writeOutput({
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
newSessionId: slashSessionId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1153,7 +1338,8 @@ async function main(): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
const errorStack = err instanceof Error ? err.stack : undefined;
|
const errorStack = err instanceof Error ? err.stack : undefined;
|
||||||
const errorCause = err instanceof Error && err.cause ? String(err.cause) : undefined;
|
const errorCause =
|
||||||
|
err instanceof Error && err.cause ? String(err.cause) : undefined;
|
||||||
log(`Agent error: ${errorMessage}`);
|
log(`Agent error: ${errorMessage}`);
|
||||||
if (errorStack) log(`Stack: ${errorStack}`);
|
if (errorStack) log(`Stack: ${errorStack}`);
|
||||||
if (errorCause) log(`Cause: ${errorCause}`);
|
if (errorCause) log(`Cause: ${errorCause}`);
|
||||||
@@ -1161,7 +1347,7 @@ async function main(): Promise<void> {
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
newSessionId: sessionId,
|
newSessionId: sessionId,
|
||||||
error: errorMessage
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,28 @@ const MEMORY_PATTERNS: Array<{
|
|||||||
regex: RegExp;
|
regex: RegExp;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ kind: 'room_norm', regex: /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, keyword: 'rule' },
|
{
|
||||||
{ kind: 'preference', regex: /(선호|원함|원한다|원하는|원했다)/i, keyword: 'preference' },
|
kind: 'room_norm',
|
||||||
{ kind: 'decision', regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, keyword: 'decision' },
|
regex:
|
||||||
{ kind: 'project_fact', regex: /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, keyword: 'memory' },
|
/(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i,
|
||||||
|
keyword: 'rule',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'preference',
|
||||||
|
regex: /(선호|원함|원한다|원하는|원했다)/i,
|
||||||
|
keyword: 'preference',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'decision',
|
||||||
|
regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i,
|
||||||
|
keyword: 'decision',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'project_fact',
|
||||||
|
regex:
|
||||||
|
/(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i,
|
||||||
|
keyword: 'memory',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function normalizeSentence(raw: string): string | null {
|
function normalizeSentence(raw: string): string | null {
|
||||||
@@ -35,15 +53,15 @@ function splitSummaryIntoSentences(summary: string): string[] {
|
|||||||
function classifyMemorySentence(content: string): SelectedCompactMemory | null {
|
function classifyMemorySentence(content: string): SelectedCompactMemory | null {
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => regex.test(content));
|
const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) =>
|
||||||
|
regex.test(content),
|
||||||
|
);
|
||||||
if (matchedPatterns.length === 0) return null;
|
if (matchedPatterns.length === 0) return null;
|
||||||
|
|
||||||
const primary = matchedPatterns[0];
|
const primary = matchedPatterns[0];
|
||||||
const keywords = [
|
const keywords = [
|
||||||
...new Set(
|
...new Set(
|
||||||
matchedPatterns
|
matchedPatterns.map((pattern) => pattern.keyword).filter(Boolean),
|
||||||
.map((pattern) => pattern.keyword)
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL } from 'url';
|
||||||
export {
|
export { computeVerificationSnapshotId } from '../../../shared/verification-snapshot.js';
|
||||||
computeVerificationSnapshotId,
|
|
||||||
} from '../../../shared/verification-snapshot.js';
|
|
||||||
|
|
||||||
export const VERIFICATION_PROFILES = [
|
export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const;
|
||||||
'test',
|
|
||||||
'typecheck',
|
|
||||||
'build',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number];
|
export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ export function normalizeWatchCiIntervalSeconds(
|
|||||||
throw new Error('poll_interval_seconds must be an integer.');
|
throw new Error('poll_interval_seconds must be an integer.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (seconds < minSeconds || seconds > MAX_WATCH_CI_INTERVAL_SECONDS) {
|
||||||
seconds < minSeconds ||
|
|
||||||
seconds > MAX_WATCH_CI_INTERVAL_SECONDS
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`poll_interval_seconds must be between ${minSeconds} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`,
|
`poll_interval_seconds must be between ${minSeconds} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { isTaskScopedIpcDir, resolveIpcDirectories } from '../src/ipc-paths.js';
|
||||||
isTaskScopedIpcDir,
|
|
||||||
resolveIpcDirectories,
|
|
||||||
} from '../src/ipc-paths.js';
|
|
||||||
|
|
||||||
describe('ipc path helpers', () => {
|
describe('ipc path helpers', () => {
|
||||||
it('detects task-scoped IPC directories', () => {
|
it('detects task-scoped IPC directories', () => {
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ describe('memory selection', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(selected).toHaveLength(2);
|
expect(selected).toHaveLength(2);
|
||||||
expect(selected[0].content).toBe('방 메모리는 새 세션 시작 시에만 주입한다.');
|
expect(selected[0].content).toBe(
|
||||||
|
'방 메모리는 새 세션 시작 시에만 주입한다.',
|
||||||
|
);
|
||||||
expect(selected[0].memoryKind).toBe('room_norm');
|
expect(selected[0].memoryKind).toBe('room_norm');
|
||||||
expect(selected[1].content).toBe('사용자는 수동 명령보다 자동 기억 형성을 원한다.');
|
expect(selected[1].content).toBe(
|
||||||
|
'사용자는 수동 명령보다 자동 기억 형성을 원한다.',
|
||||||
|
);
|
||||||
expect(selected[1].memoryKind).toBe('preference');
|
expect(selected[1].memoryKind).toBe('preference');
|
||||||
expect(selected.every((memory) => memory.keywords.includes('room:ejclaw'))).toBe(true);
|
expect(
|
||||||
|
selected.every((memory) => memory.keywords.includes('room:ejclaw')),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns no memories for purely operational summaries', () => {
|
it('returns no memories for purely operational summaries', () => {
|
||||||
@@ -37,7 +43,9 @@ describe('memory selection', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(selected).toHaveLength(1);
|
expect(selected).toHaveLength(1);
|
||||||
expect(selected[0].content).toBe('배포 전에 항상 테스트를 돌리는 것이 원칙이다.');
|
expect(selected[0].content).toBe(
|
||||||
|
'배포 전에 항상 테스트를 돌리는 것이 원칙이다.',
|
||||||
|
);
|
||||||
expect(selected[0].memoryKind).toBe('room_norm');
|
expect(selected[0].memoryKind).toBe('room_norm');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,11 +110,11 @@ describe('claude reviewer runtime guard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds a read-only sandbox with normalized protected paths', () => {
|
it('builds a read-only sandbox with normalized protected paths', () => {
|
||||||
const sandbox = buildClaudeReadonlySandboxSettings([
|
const sandbox = buildClaudeReadonlySandboxSettings(
|
||||||
'/repo/work',
|
['/repo/work', '/repo/work', '/repo/owner/../owner'],
|
||||||
'/repo/work',
|
'linux',
|
||||||
'/repo/owner/../owner',
|
'strict',
|
||||||
], 'linux', 'strict');
|
);
|
||||||
|
|
||||||
expect(sandbox).toMatchObject({
|
expect(sandbox).toMatchObject({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -157,12 +157,10 @@ describe('claude reviewer runtime guard', () => {
|
|||||||
|
|
||||||
it('flags mutating shell commands', () => {
|
it('flags mutating shell commands', () => {
|
||||||
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false);
|
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(false);
|
||||||
expect(isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"')).toBe(
|
expect(
|
||||||
false,
|
isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"'),
|
||||||
);
|
).toBe(false);
|
||||||
expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe(
|
expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe(true);
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(isReviewerMutatingShellCommand('git status')).toBe(false);
|
expect(isReviewerMutatingShellCommand('git status')).toBe(false);
|
||||||
expect(isReviewerMutatingShellCommand('npm test')).toBe(false);
|
expect(isReviewerMutatingShellCommand('npm test')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -263,7 +261,9 @@ describe('claude reviewer runtime guard', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow();
|
expect(() =>
|
||||||
|
assertReadonlyWorkspaceRepoConnectivity(env, true),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -286,7 +286,9 @@ describe('claude reviewer runtime guard', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow();
|
expect(() =>
|
||||||
|
assertReadonlyWorkspaceRepoConnectivity(env, true),
|
||||||
|
).not.toThrow();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ import {
|
|||||||
|
|
||||||
describe('runner verification helpers', () => {
|
describe('runner verification helpers', () => {
|
||||||
it('computes the same snapshot when excluded files change', () => {
|
it('computes the same snapshot when excluded files change', () => {
|
||||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-runner-snapshot-'));
|
const repoDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ejclaw-runner-snapshot-'),
|
||||||
|
);
|
||||||
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true });
|
fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const x = 1;\n');
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'src', 'index.ts'),
|
||||||
|
'export const x = 1;\n',
|
||||||
|
);
|
||||||
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n');
|
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n');
|
||||||
|
|
||||||
const first = computeVerificationSnapshotId(repoDir);
|
const first = computeVerificationSnapshotId(repoDir);
|
||||||
|
|||||||
@@ -102,7 +102,11 @@ export class CodexAppServerClient {
|
|||||||
if (this.proc) return;
|
if (this.proc) return;
|
||||||
|
|
||||||
const codexPackagePath = this.require.resolve('@openai/codex/package.json');
|
const codexPackagePath = this.require.resolve('@openai/codex/package.json');
|
||||||
const codexBin = path.join(path.dirname(codexPackagePath), 'bin', 'codex.js');
|
const codexBin = path.join(
|
||||||
|
path.dirname(codexPackagePath),
|
||||||
|
'bin',
|
||||||
|
'codex.js',
|
||||||
|
);
|
||||||
|
|
||||||
this.proc = spawn(process.execPath, [codexBin, 'app-server'], {
|
this.proc = spawn(process.execPath, [codexBin, 'app-server'], {
|
||||||
cwd: this.cwd,
|
cwd: this.cwd,
|
||||||
@@ -213,15 +217,17 @@ export class CodexAppServerClient {
|
|||||||
throw new Error('A Codex app-server turn is already active.');
|
throw new Error('A Codex app-server turn is already active.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
|
const turnPromise = new Promise<CodexAppServerTurnResult>(
|
||||||
this.activeTurn = {
|
(resolve, reject) => {
|
||||||
threadId,
|
this.activeTurn = {
|
||||||
state: createInitialAppServerTurnState(),
|
threadId,
|
||||||
onProgress: options.onProgress,
|
state: createInitialAppServerTurnState(),
|
||||||
resolve,
|
onProgress: options.onProgress,
|
||||||
reject,
|
resolve,
|
||||||
};
|
reject,
|
||||||
});
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let turnId = '';
|
let turnId = '';
|
||||||
try {
|
try {
|
||||||
@@ -285,14 +291,16 @@ export class CodexAppServerClient {
|
|||||||
throw new Error('A Codex app-server turn is already active.');
|
throw new Error('A Codex app-server turn is already active.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
|
const turnPromise = new Promise<CodexAppServerTurnResult>(
|
||||||
this.activeTurn = {
|
(resolve, reject) => {
|
||||||
threadId,
|
this.activeTurn = {
|
||||||
state: createInitialAppServerTurnState(),
|
threadId,
|
||||||
resolve,
|
state: createInitialAppServerTurnState(),
|
||||||
reject,
|
resolve,
|
||||||
};
|
reject,
|
||||||
});
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.request('thread/compact/start', { threadId });
|
await this.request('thread/compact/start', { threadId });
|
||||||
|
|||||||
@@ -37,10 +37,7 @@ export type AppServerTurnEvent =
|
|||||||
turn?: {
|
turn?: {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
error?:
|
error?: { message?: string | null } | string | null;
|
||||||
| { message?: string | null }
|
|
||||||
| string
|
|
||||||
| null;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,10 +99,7 @@ function normalizeStructuredOutput(result: string | null): {
|
|||||||
const envelope = parsed?.ejclaw;
|
const envelope = parsed?.ejclaw;
|
||||||
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
||||||
if (envelope.visibility === 'silent') {
|
if (envelope.visibility === 'silent') {
|
||||||
if (
|
if (envelope.verdict !== undefined && envelope.verdict !== 'silent') {
|
||||||
envelope.verdict !== undefined &&
|
|
||||||
envelope.verdict !== 'silent'
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
output: { visibility: 'public', text: result },
|
output: { visibility: 'public', text: result },
|
||||||
@@ -225,7 +222,10 @@ function drainIpcInput(): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } {
|
function extractImagePaths(text: string): {
|
||||||
|
cleanText: string;
|
||||||
|
imagePaths: string[];
|
||||||
|
} {
|
||||||
/** SSOT: src/agent-protocol.ts — keep in sync */
|
/** SSOT: src/agent-protocol.ts — keep in sync */
|
||||||
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
|
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
|
||||||
const imagePaths: string[] = [];
|
const imagePaths: string[] = [];
|
||||||
@@ -285,24 +285,28 @@ async function executeAppServerTurn(
|
|||||||
retryCount = 0,
|
retryCount = 0,
|
||||||
): Promise<{ result: string | null; error?: string }> {
|
): Promise<{ result: string | null; error?: string }> {
|
||||||
let lastProgressMessage: string | null = null;
|
let lastProgressMessage: string | null = null;
|
||||||
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
|
const activeTurn = await client.startTurn(
|
||||||
cwd: EFFECTIVE_CWD,
|
threadId,
|
||||||
model: CODEX_MODEL || undefined,
|
parseAppServerInput(prompt),
|
||||||
effort: CODEX_EFFORT || undefined,
|
{
|
||||||
onProgress: (message) => {
|
cwd: EFFECTIVE_CWD,
|
||||||
const trimmed = message.trim();
|
model: CODEX_MODEL || undefined,
|
||||||
if (!trimmed || trimmed === lastProgressMessage) {
|
effort: CODEX_EFFORT || undefined,
|
||||||
return;
|
onProgress: (message) => {
|
||||||
}
|
const trimmed = message.trim();
|
||||||
lastProgressMessage = trimmed;
|
if (!trimmed || trimmed === lastProgressMessage) {
|
||||||
writeOutput({
|
return;
|
||||||
status: 'success',
|
}
|
||||||
phase: 'progress',
|
lastProgressMessage = trimmed;
|
||||||
...normalizeStructuredOutput(trimmed),
|
writeOutput({
|
||||||
newSessionId: threadId,
|
status: 'success',
|
||||||
});
|
phase: 'progress',
|
||||||
|
...normalizeStructuredOutput(trimmed),
|
||||||
|
newSessionId: threadId,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
let elapsedMs = 0;
|
let elapsedMs = 0;
|
||||||
let polling = true;
|
let polling = true;
|
||||||
@@ -360,7 +364,8 @@ async function executeAppServerTurn(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
|
error:
|
||||||
|
state.errorMessage || `Codex turn finished with status ${state.status}`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
polling = false;
|
polling = false;
|
||||||
@@ -451,7 +456,11 @@ async function runAppServerSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log('Starting app-server turn...');
|
log('Starting app-server turn...');
|
||||||
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
|
const { result, error } = await executeAppServerTurn(
|
||||||
|
client,
|
||||||
|
threadId,
|
||||||
|
prompt,
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
const normalized = normalizeStructuredOutput(result || null);
|
const normalized = normalizeStructuredOutput(result || null);
|
||||||
|
|||||||
@@ -167,7 +167,9 @@ describe('codex reviewer runtime guard', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow();
|
expect(() =>
|
||||||
|
assertReadonlyWorkspaceRepoConnectivity(env, true),
|
||||||
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -190,7 +192,9 @@ describe('codex reviewer runtime guard', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(() => assertReadonlyWorkspaceRepoConnectivity(env, true)).not.toThrow();
|
expect(() =>
|
||||||
|
assertReadonlyWorkspaceRepoConnectivity(env, true),
|
||||||
|
).not.toThrow();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export function normalizeLegacyRegisteredGroupsTable(database: Database): void {
|
|||||||
const hasIsMain = legacyCols.some((col) => col.name === 'is_main');
|
const hasIsMain = legacyCols.some((col) => col.name === 'is_main');
|
||||||
const hasAgentType = legacyCols.some((col) => col.name === 'agent_type');
|
const hasAgentType = legacyCols.some((col) => col.name === 'agent_type');
|
||||||
const hasWorkDir = legacyCols.some((col) => col.name === 'work_dir');
|
const hasWorkDir = legacyCols.some((col) => col.name === 'work_dir');
|
||||||
const hasAgentConfig = legacyCols.some((col) => col.name === 'agent_config');
|
const hasAgentConfig = legacyCols.some(
|
||||||
|
(col) => col.name === 'agent_config',
|
||||||
|
);
|
||||||
const hasContainerConfig = legacyCols.some(
|
const hasContainerConfig = legacyCols.some(
|
||||||
(col) => col.name === 'container_config',
|
(col) => col.name === 'container_config',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,7 +65,18 @@ function backupLegacyRows(database: Database): number {
|
|||||||
requires_trigger, is_main, agent_type, work_dir
|
requires_trigger, is_main, agent_type, work_dir
|
||||||
FROM registered_groups`,
|
FROM registered_groups`,
|
||||||
)
|
)
|
||||||
.all() as Array<Record<string, unknown>>;
|
.all() as Array<{
|
||||||
|
jid: string;
|
||||||
|
name: string;
|
||||||
|
folder: string;
|
||||||
|
trigger_pattern: string;
|
||||||
|
added_at: string;
|
||||||
|
agent_config: string | null;
|
||||||
|
requires_trigger: number | null;
|
||||||
|
is_main: number | null;
|
||||||
|
agent_type: string | null;
|
||||||
|
work_dir: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
const insert = database.prepare(
|
const insert = database.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups_legacy_backup (
|
`INSERT OR REPLACE INTO registered_groups_legacy_backup (
|
||||||
|
|||||||
@@ -105,43 +105,30 @@ describe('commandExists', () => {
|
|||||||
describe('canUseLinuxBubblewrapReadonlySandbox', () => {
|
describe('canUseLinuxBubblewrapReadonlySandbox', () => {
|
||||||
it('returns false outside linux', () => {
|
it('returns false outside linux', () => {
|
||||||
expect(
|
expect(
|
||||||
canUseLinuxBubblewrapReadonlySandbox(
|
canUseLinuxBubblewrapReadonlySandbox('macos', () => {
|
||||||
'macos',
|
throw new Error('should not probe commands on macOS');
|
||||||
() => {
|
}),
|
||||||
throw new Error('should not probe commands on macOS');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when bwrap is not installed', () => {
|
it('returns false when bwrap is not installed', () => {
|
||||||
expect(
|
expect(canUseLinuxBubblewrapReadonlySandbox('linux', () => false)).toBe(
|
||||||
canUseLinuxBubblewrapReadonlySandbox(
|
false,
|
||||||
'linux',
|
);
|
||||||
() => false,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true when linux bwrap readonly probe succeeds', () => {
|
it('returns true when linux bwrap readonly probe succeeds', () => {
|
||||||
expect(
|
expect(
|
||||||
canUseLinuxBubblewrapReadonlySandbox(
|
canUseLinuxBubblewrapReadonlySandbox('linux', () => true, (() =>
|
||||||
'linux',
|
Buffer.from('')) as unknown as typeof import('child_process').execSync),
|
||||||
() => true,
|
|
||||||
(() => Buffer.from('')) as typeof import('child_process').execSync,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when linux bwrap readonly probe fails', () => {
|
it('returns false when linux bwrap readonly probe fails', () => {
|
||||||
expect(
|
expect(
|
||||||
canUseLinuxBubblewrapReadonlySandbox(
|
canUseLinuxBubblewrapReadonlySandbox('linux', () => true, (() => {
|
||||||
'linux',
|
throw new Error('permission denied');
|
||||||
() => true,
|
}) as typeof import('child_process').execSync),
|
||||||
(() => {
|
|
||||||
throw new Error('permission denied');
|
|
||||||
}) as typeof import('child_process').execSync,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -157,8 +144,9 @@ describe('getAppArmorRestrictUnprivilegedUsernsValue', () => {
|
|||||||
|
|
||||||
it('returns the trimmed proc sysctl value', () => {
|
it('returns the trimmed proc sysctl value', () => {
|
||||||
expect(
|
expect(
|
||||||
getAppArmorRestrictUnprivilegedUsernsValue((() =>
|
getAppArmorRestrictUnprivilegedUsernsValue(
|
||||||
'1\n') as typeof import('fs').readFileSync),
|
(() => '1\n') as unknown as typeof import('fs').readFileSync,
|
||||||
|
),
|
||||||
).toBe('1');
|
).toBe('1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -252,9 +240,11 @@ describe('ensureLinuxReadonlySandboxAppArmorSupport', () => {
|
|||||||
const writes: string[] = [];
|
const writes: string[] = [];
|
||||||
const commands: string[] = [];
|
const commands: string[] = [];
|
||||||
const fileContents = new Map<string, string>([
|
const fileContents = new Map<string, string>([
|
||||||
['/etc/sysctl.d/90-ejclaw-sandbox.conf',
|
[
|
||||||
|
'/etc/sysctl.d/90-ejclaw-sandbox.conf',
|
||||||
'# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' +
|
'# Managed by EJClaw setup to allow bubblewrap readonly sandboxing.\n' +
|
||||||
'kernel.apparmor_restrict_unprivileged_userns=0\n'],
|
'kernel.apparmor_restrict_unprivileged_userns=0\n',
|
||||||
|
],
|
||||||
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
|
['/proc/sys/kernel/apparmor_restrict_unprivileged_userns', '1\n'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -301,14 +291,17 @@ describe('ensureLinuxReadonlySandboxAppArmorSupport', () => {
|
|||||||
apparmorRestrictUnprivilegedUserns: '1',
|
apparmorRestrictUnprivilegedUserns: '1',
|
||||||
sandboxCapable: false,
|
sandboxCapable: false,
|
||||||
isRootUser: true,
|
isRootUser: true,
|
||||||
readFileSyncFn: ((target) => {
|
readFileSyncFn: ((target: import('fs').PathOrFileDescriptor) => {
|
||||||
const value = fileContents.get(String(target));
|
const value = fileContents.get(String(target));
|
||||||
if (value == null) throw new Error('missing');
|
if (value == null) throw new Error('missing');
|
||||||
return value;
|
return value;
|
||||||
}) as typeof import('fs').readFileSync,
|
}) as unknown as typeof import('fs').readFileSync,
|
||||||
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
|
mkdirSyncFn: (() => undefined) as typeof import('fs').mkdirSync,
|
||||||
writeFileSyncFn: (() => undefined) as typeof import('fs').writeFileSync,
|
writeFileSyncFn: (() => undefined) as typeof import('fs').writeFileSync,
|
||||||
execFn: (() => Buffer.from('')) as typeof import('child_process').execSync,
|
execFn: (() =>
|
||||||
|
Buffer.from(
|
||||||
|
'',
|
||||||
|
)) as unknown as typeof import('child_process').execSync,
|
||||||
}),
|
}),
|
||||||
).toBe('failed');
|
).toBe('failed');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -177,10 +177,7 @@ export function ensureLinuxReadonlySandboxAppArmorSupport(options?: {
|
|||||||
const sandboxCapable =
|
const sandboxCapable =
|
||||||
options?.sandboxCapable ?? canUseLinuxBubblewrapReadonlySandbox(platform);
|
options?.sandboxCapable ?? canUseLinuxBubblewrapReadonlySandbox(platform);
|
||||||
|
|
||||||
if (
|
if (apparmorRestrictUnprivilegedUserns !== '1' || sandboxCapable) {
|
||||||
apparmorRestrictUnprivilegedUserns !== '1' ||
|
|
||||||
sandboxCapable
|
|
||||||
) {
|
|
||||||
return 'not-needed';
|
return 'not-needed';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,9 +208,8 @@ export function ensureLinuxReadonlySandboxAppArmorSupport(options?: {
|
|||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
|
|
||||||
const appliedValue = getAppArmorRestrictUnprivilegedUsernsValue(
|
const appliedValue =
|
||||||
readFileSyncFn,
|
getAppArmorRestrictUnprivilegedUsernsValue(readFileSyncFn);
|
||||||
);
|
|
||||||
if (appliedValue !== '0') {
|
if (appliedValue !== '0') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`kernel.apparmor_restrict_unprivileged_userns remained ${appliedValue ?? 'unavailable'} after sysctl apply`,
|
`kernel.apparmor_restrict_unprivileged_userns remained ${appliedValue ?? 'unavailable'} after sysctl apply`,
|
||||||
|
|||||||
@@ -77,24 +77,24 @@ describe('restartStackServices', () => {
|
|||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
restartStackServices(tempRoot, {
|
restartStackServices(tempRoot, {
|
||||||
serviceManager: 'nohup',
|
serviceManager: 'none',
|
||||||
}),
|
}),
|
||||||
).toThrow('restart:stack only supports Linux systemd services in this repo');
|
).toThrow(
|
||||||
|
'restart:stack only supports Linux systemd services in this repo',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => {
|
it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
|
|
||||||
const execFileSyncImpl = vi
|
const execFileSyncImpl = vi.fn().mockImplementationOnce(() => {
|
||||||
.fn()
|
const error = new Error('unit missing');
|
||||||
.mockImplementationOnce(() => {
|
Object.assign(error, {
|
||||||
const error = new Error('unit missing');
|
stderr: 'Unit ejclaw-stack-restart.service not found.',
|
||||||
Object.assign(error, {
|
|
||||||
stderr: 'Unit ejclaw-stack-restart.service not found.',
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
const services = restartStackServices(tempRoot, {
|
const services = restartStackServices(tempRoot, {
|
||||||
execFileSyncImpl,
|
execFileSyncImpl,
|
||||||
@@ -147,7 +147,8 @@ describe('restartStackServices', () => {
|
|||||||
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
const execFileSyncImpl = vi.fn().mockImplementation(() => {
|
||||||
const error = new Error('job failed');
|
const error = new Error('job failed');
|
||||||
Object.assign(error, {
|
Object.assign(error, {
|
||||||
stderr: 'Job for ejclaw-stack-restart.service failed because the control process exited with error code.',
|
stderr:
|
||||||
|
'Job for ejclaw-stack-restart.service failed because the control process exited with error code.',
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function restartStackServicesDirect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
||||||
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
|
const systemctlArgs = (deps.runningAsRoot ?? isRoot()) ? [] : ['--user'];
|
||||||
|
|
||||||
execImpl('systemctl', [...systemctlArgs, 'restart', ...services], {
|
execImpl('systemctl', [...systemctlArgs, 'restart', ...services], {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
@@ -64,7 +64,7 @@ export function restartStackServices(
|
|||||||
|
|
||||||
const services = getConfiguredServiceNames(projectRoot);
|
const services = getConfiguredServiceNames(projectRoot);
|
||||||
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
||||||
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
|
const systemctlArgs = (deps.runningAsRoot ?? isRoot()) ? [] : ['--user'];
|
||||||
|
|
||||||
if (deps.direct) {
|
if (deps.direct) {
|
||||||
return restartStackServicesDirect(projectRoot, deps);
|
return restartStackServicesDirect(projectRoot, deps);
|
||||||
|
|||||||
@@ -118,9 +118,7 @@ describe('systemd unit generation', () => {
|
|||||||
'/home/user',
|
'/home/user',
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(unit).toContain(
|
expect(unit).toContain('ExecStart=/usr/bin/bun /srv/ejclaw/dist/index.js');
|
||||||
'ExecStart=/usr/bin/bun /srv/ejclaw/dist/index.js',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves EnvironmentFile and extraEnv in the actual builder', () => {
|
it('preserves EnvironmentFile and extraEnv in the actual builder', () => {
|
||||||
|
|||||||
@@ -52,9 +52,12 @@ describe('verify service checks', () => {
|
|||||||
execSyncMock.mockReturnValue(undefined);
|
execSyncMock.mockReturnValue(undefined);
|
||||||
|
|
||||||
expect(checkSystemdService('ejclaw')).toBe('running');
|
expect(checkSystemdService('ejclaw')).toBe('running');
|
||||||
expect(execSyncMock).toHaveBeenCalledWith('systemctl --user is-active ejclaw', {
|
expect(execSyncMock).toHaveBeenCalledWith(
|
||||||
stdio: 'ignore',
|
'systemctl --user is-active ejclaw',
|
||||||
});
|
{
|
||||||
|
stdio: 'ignore',
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks systemd services in both explicit scopes', () => {
|
it('checks systemd services in both explicit scopes', () => {
|
||||||
@@ -71,8 +74,12 @@ describe('verify service checks', () => {
|
|||||||
throw new Error(`unexpected command: ${cmd}`);
|
throw new Error(`unexpected command: ${cmd}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe('running');
|
expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe(
|
||||||
expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe('not_found');
|
'running',
|
||||||
|
);
|
||||||
|
expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe(
|
||||||
|
'not_found',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => {
|
it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => {
|
||||||
@@ -87,9 +94,9 @@ describe('verify service checks', () => {
|
|||||||
fs.writeFileSync(plistPath, '<plist />');
|
fs.writeFileSync(plistPath, '<plist />');
|
||||||
execSyncMock.mockReturnValue('');
|
execSyncMock.mockReturnValue('');
|
||||||
|
|
||||||
expect(
|
expect(checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath)).toBe(
|
||||||
checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath),
|
'stopped',
|
||||||
).toBe('stopped');
|
);
|
||||||
|
|
||||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@@ -111,7 +118,7 @@ describe('verify service checks', () => {
|
|||||||
|
|
||||||
const killSpy = vi
|
const killSpy = vi
|
||||||
.spyOn(process, 'kill')
|
.spyOn(process, 'kill')
|
||||||
.mockImplementation((_pid: number, _signal?: number | NodeJS.Signals) => true);
|
.mockImplementation((_pid: number, _signal?: string | number) => true);
|
||||||
|
|
||||||
expect(checkNohupService(tempRoot, 'ejclaw')).toBe('running');
|
expect(checkNohupService(tempRoot, 'ejclaw')).toBe('running');
|
||||||
|
|
||||||
@@ -121,7 +128,10 @@ describe('verify service checks', () => {
|
|||||||
|
|
||||||
it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => {
|
it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||||
fs.writeFileSync(path.join(tempRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n');
|
fs.writeFileSync(
|
||||||
|
path.join(tempRoot, 'start-ejclaw-codex.sh'),
|
||||||
|
'#!/bin/bash\n',
|
||||||
|
);
|
||||||
|
|
||||||
expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped');
|
expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped');
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export function checkLaunchdService(label: string): ServiceStatus {
|
|||||||
try {
|
try {
|
||||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||||
if (output.includes(label)) {
|
if (output.includes(label)) {
|
||||||
const line = output.split('\n').find((candidate) => candidate.includes(label));
|
const line = output
|
||||||
|
.split('\n')
|
||||||
|
.find((candidate) => candidate.includes(label));
|
||||||
if (line) {
|
if (line) {
|
||||||
const pidField = line.trim().split(/\s+/)[0];
|
const pidField = line.trim().split(/\s+/)[0];
|
||||||
return pidField !== '-' && pidField ? 'running' : 'stopped';
|
return pidField !== '-' && pidField ? 'running' : 'stopped';
|
||||||
|
|||||||
@@ -64,24 +64,22 @@ describe('getInterruptedRecoveryCandidates', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual(
|
expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual([
|
||||||
[
|
{
|
||||||
{
|
chatJid: 'dc:1',
|
||||||
chatJid: 'dc:1',
|
groupFolder: 'group-one',
|
||||||
groupFolder: 'group-one',
|
status: 'processing',
|
||||||
status: 'processing',
|
pendingMessages: true,
|
||||||
pendingMessages: true,
|
pendingTasks: 0,
|
||||||
pendingTasks: 0,
|
},
|
||||||
},
|
{
|
||||||
{
|
chatJid: 'dc:2',
|
||||||
chatJid: 'dc:2',
|
groupFolder: 'group-two',
|
||||||
groupFolder: 'group-two',
|
status: 'idle',
|
||||||
status: 'idle',
|
pendingMessages: false,
|
||||||
pendingMessages: false,
|
pendingTasks: 0,
|
||||||
pendingTasks: 0,
|
},
|
||||||
},
|
]);
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty when there is no explicit restart context', () => {
|
it('returns empty when there is no explicit restart context', () => {
|
||||||
|
|||||||
23
test/vitest-env.ts
Normal file
23
test/vitest-env.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const CANONICAL_DISCORD_KEYS = new Set([
|
||||||
|
'DISCORD_OWNER_BOT_TOKEN',
|
||||||
|
'DISCORD_REVIEWER_BOT_TOKEN',
|
||||||
|
'DISCORD_ARBITER_BOT_TOKEN',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CANONICAL_SESSION_COMMAND_KEYS = new Set([
|
||||||
|
'SESSION_COMMAND_ALLOWED_SENDERS',
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (key.startsWith('DISCORD_') && key.endsWith('_BOT_TOKEN')) {
|
||||||
|
if (!CANONICAL_DISCORD_KEYS.has(key)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key.startsWith('SESSION_COMMAND_')) {
|
||||||
|
if (!CANONICAL_SESSION_COMMAND_KEYS.has(key)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tsconfig.check.json
Normal file
19
tsconfig.check.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"setup/**/*",
|
||||||
|
"scripts/**/*",
|
||||||
|
"test/**/*",
|
||||||
|
"vitest*.ts",
|
||||||
|
"runners/**/test/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", "dist", "runners/**/dist"]
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
setupFiles: ['test/vitest-env.ts'],
|
||||||
include: [
|
include: [
|
||||||
'src/**/*.test.ts',
|
'src/**/*.test.ts',
|
||||||
'setup/**/*.test.ts',
|
'setup/**/*.test.ts',
|
||||||
|
|||||||
Reference in New Issue
Block a user