Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw
# Conflicts: # prompts/owner-common-platform.md # runners/shared/src/agent-protocol.ts # runners/shared/test/agent-protocol.test.ts # src/db/bootstrap.test.ts # src/db/migrations/index.ts # src/message-turn-controller.test.ts # src/message-turn-controller.ts # src/outbound-attachments.ts # src/paired-execution-context.test.ts
This commit is contained in:
16
.env.example
16
.env.example
@@ -36,6 +36,22 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat
|
||||
# STATUS_SHOW_ROOMS=true # Show room status in dashboard
|
||||
# STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info
|
||||
|
||||
# --- Codex warm-up (optional, disabled by default) ---
|
||||
# Sends a tiny real Codex prompt only when both 5h and 7d usage are 0%, so
|
||||
# reset countdown starts once after a fresh quota window. Keep disabled if
|
||||
# OpenAI changes policy or starts blocking automated warm-up prompts.
|
||||
# Normally only set this one line; the other knobs have conservative defaults.
|
||||
# CODEX_WARMUP_ENABLED=false
|
||||
# CODEX_WARMUP_PROMPT=Reply exactly OK. Do not run tools.
|
||||
# CODEX_WARMUP_MODEL= # Defaults to CODEX_MODEL
|
||||
# CODEX_WARMUP_INTERVAL_MS=300000 # Check every 5min
|
||||
# CODEX_WARMUP_MIN_INTERVAL_MS=18300000 # Per-account minimum gap: 5h5m
|
||||
# CODEX_WARMUP_STAGGER_MS=1800000 # Global gap between accounts: 30min
|
||||
# CODEX_WARMUP_MAX_USAGE_PCT=0 # Only warm accounts at/below this 5h usage
|
||||
# CODEX_WARMUP_MAX_D7_USAGE_PCT=0 # Only warm accounts at/below this 7d usage
|
||||
# CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES=2
|
||||
# CODEX_WARMUP_FAILURE_COOLDOWN_MS=21600000 # Back off 6h after repeated failure
|
||||
|
||||
# --- Fallback provider (when Claude/Codex both unavailable) ---
|
||||
# FALLBACK_ENABLED=true # Enable fallback provider
|
||||
# FALLBACK_PROVIDER_NAME=kimi # Provider name for logging
|
||||
|
||||
22
apps/dashboard/index.html
Normal file
22
apps/dashboard/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#2b3726" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="EJClaw" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/icon-192.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>EJClaw Ops</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/dashboard/public/icons/icon-192.png
Normal file
BIN
apps/dashboard/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 B |
8
apps/dashboard/public/icons/icon-192.svg
Normal file
8
apps/dashboard/public/icons/icon-192.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="EJClaw">
|
||||
<rect width="192" height="192" rx="46" fill="#2b3726" />
|
||||
<path d="M39 122c18 21 47 29 81 17 18-6 30-18 36-35" fill="none" stroke="#f7edda" stroke-width="14" stroke-linecap="round" />
|
||||
<path d="M50 78c16-24 45-36 76-25 13 5 24 13 32 25" fill="none" stroke="#bf5f2c" stroke-width="14" stroke-linecap="round" />
|
||||
<circle cx="74" cy="94" r="11" fill="#f7edda" />
|
||||
<circle cx="118" cy="94" r="11" fill="#f7edda" />
|
||||
<path d="M88 126h21" stroke="#f7edda" stroke-width="10" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 627 B |
BIN
apps/dashboard/public/icons/icon-512.png
Normal file
BIN
apps/dashboard/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
8
apps/dashboard/public/icons/icon-512.svg
Normal file
8
apps/dashboard/public/icons/icon-512.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="EJClaw">
|
||||
<rect width="512" height="512" rx="124" fill="#2b3726" />
|
||||
<path d="M104 325c48 58 126 78 217 47 48-16 80-49 96-93" fill="none" stroke="#f7edda" stroke-width="38" stroke-linecap="round" />
|
||||
<path d="M134 208c43-64 120-96 203-66 36 13 64 35 86 66" fill="none" stroke="#bf5f2c" stroke-width="38" stroke-linecap="round" />
|
||||
<circle cx="198" cy="250" r="30" fill="#f7edda" />
|
||||
<circle cx="314" cy="250" r="30" fill="#f7edda" />
|
||||
<path d="M235 336h56" stroke="#f7edda" stroke-width="28" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 641 B |
27
apps/dashboard/public/manifest.webmanifest
Normal file
27
apps/dashboard/public/manifest.webmanifest
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "EJClaw Ops",
|
||||
"short_name": "EJClaw",
|
||||
"description": "EJClaw operations console",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "browser"],
|
||||
"background_color": "#f3efe4",
|
||||
"theme_color": "#2b3726",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
87
apps/dashboard/public/offline.html
Normal file
87
apps/dashboard/public/offline.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#2b3726" />
|
||||
<title>EJClaw Offline</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
Avenir Next,
|
||||
Segoe UI Variable,
|
||||
Noto Sans KR,
|
||||
ui-sans-serif,
|
||||
sans-serif;
|
||||
background: #f3efe4;
|
||||
color: #1c211c;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 18%,
|
||||
rgba(191, 95, 44, 0.2),
|
||||
transparent 26rem
|
||||
),
|
||||
linear-gradient(135deg, #f4ead5, #edf1df);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(420px, calc(100% - 32px));
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(43, 55, 38, 0.16);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 240, 0.86);
|
||||
box-shadow: 0 24px 70px rgba(44, 39, 25, 0.16);
|
||||
}
|
||||
|
||||
span {
|
||||
color: #8f351b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 18px;
|
||||
color: #6d735f;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: #fffaf0;
|
||||
background: linear-gradient(135deg, #bf5f2c, #8f351b);
|
||||
font: inherit;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<span>EJClaw</span>
|
||||
<h1>Offline</h1>
|
||||
<p>네트워크가 복구되면 대시보드를 다시 불러옵니다.</p>
|
||||
<button type="button" onclick="window.location.reload()">Retry</button>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
78
apps/dashboard/public/sw.js
Normal file
78
apps/dashboard/public/sw.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const CACHE_NAME = 'ejclaw-dashboard-v1';
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/offline.html',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-192.svg',
|
||||
'/icons/icon-512.svg',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'GET' || url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put('/', copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() =>
|
||||
caches
|
||||
.match('/')
|
||||
.then((cached) => cached || caches.match('/offline.html')),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then((response) => {
|
||||
if (!response.ok) return response;
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
return response;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
4439
apps/dashboard/src/App.tsx
Normal file
4439
apps/dashboard/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
441
apps/dashboard/src/api.ts
Normal file
441
apps/dashboard/src/api.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
export interface DashboardOverview {
|
||||
generatedAt: string;
|
||||
services: Array<{
|
||||
serviceId: string;
|
||||
assistantName: string;
|
||||
agentType: string;
|
||||
updatedAt: string;
|
||||
totalRooms: number;
|
||||
activeRooms: number;
|
||||
}>;
|
||||
rooms: {
|
||||
total: number;
|
||||
active: number;
|
||||
waiting: number;
|
||||
inactive: number;
|
||||
};
|
||||
tasks: {
|
||||
total: number;
|
||||
active: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
watchers: {
|
||||
active: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
};
|
||||
};
|
||||
usage: {
|
||||
rows: Array<{
|
||||
name: string;
|
||||
h5pct: number;
|
||||
h5reset: string;
|
||||
d7pct: number;
|
||||
d7reset: string;
|
||||
}>;
|
||||
fetchedAt: string | null;
|
||||
};
|
||||
operations?: {
|
||||
serviceRestarts: DashboardServiceRestart[];
|
||||
};
|
||||
inbox: Array<{
|
||||
id: string;
|
||||
groupKey: string;
|
||||
kind:
|
||||
| 'pending-room'
|
||||
| 'reviewer-request'
|
||||
| 'approval'
|
||||
| 'arbiter-request'
|
||||
| 'ci-failure'
|
||||
| 'mention';
|
||||
severity: 'info' | 'warn' | 'error';
|
||||
title: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
lastOccurredAt: string;
|
||||
createdAt: string;
|
||||
occurrences: number;
|
||||
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
||||
roomJid?: string;
|
||||
roomName?: string;
|
||||
groupFolder?: string;
|
||||
serviceId?: string;
|
||||
taskId?: string;
|
||||
taskStatus?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DashboardServiceRestart {
|
||||
id: string;
|
||||
target: 'stack';
|
||||
requestedAt: string;
|
||||
completedAt: string | null;
|
||||
status: 'running' | 'success' | 'failed';
|
||||
services: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StatusSnapshot {
|
||||
serviceId: string;
|
||||
agentType: string;
|
||||
assistantName: string;
|
||||
updatedAt: string;
|
||||
entries: Array<{
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
agentType: string;
|
||||
status: 'processing' | 'waiting' | 'inactive';
|
||||
elapsedMs: number | null;
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DashboardRoomActivity {
|
||||
serviceId: string;
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
agentType: string;
|
||||
status: 'processing' | 'waiting' | 'inactive';
|
||||
elapsedMs: number | null;
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
isFromMe: boolean;
|
||||
isBotMessage: boolean;
|
||||
sourceKind: string;
|
||||
}>;
|
||||
pairedTask: {
|
||||
id: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
roundTripCount: number;
|
||||
updatedAt: string;
|
||||
currentTurn: {
|
||||
turnId: string;
|
||||
role: string;
|
||||
intentKind: string;
|
||||
state: string;
|
||||
attemptNo: number;
|
||||
executorServiceId: string | null;
|
||||
executorAgentType: string | null;
|
||||
activeRunId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
lastError: string | null;
|
||||
progressText: string | null;
|
||||
progressUpdatedAt: string | null;
|
||||
} | null;
|
||||
outputs: Array<{
|
||||
id: number;
|
||||
turnNumber: number;
|
||||
role: string;
|
||||
verdict: string | null;
|
||||
createdAt: string;
|
||||
outputText: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface DashboardTask {
|
||||
id: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
agentType: string | null;
|
||||
ciProvider: string | null;
|
||||
ciMetadata: string | null;
|
||||
scheduleType: string;
|
||||
scheduleValue: string;
|
||||
contextMode: string;
|
||||
nextRun: string | null;
|
||||
lastRun: string | null;
|
||||
lastResult: string | null;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
suspendedUntil: string | null;
|
||||
createdAt: string;
|
||||
promptPreview: string;
|
||||
promptLength: number;
|
||||
isWatcher: boolean;
|
||||
}
|
||||
|
||||
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
||||
export type DashboardInboxAction = 'run' | 'decline' | 'dismiss';
|
||||
export type DashboardServiceAction = 'restart';
|
||||
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
|
||||
export type DashboardTaskContextMode = 'group' | 'isolated';
|
||||
|
||||
export interface CreateScheduledTaskInput {
|
||||
roomJid: string;
|
||||
prompt: string;
|
||||
scheduleType: DashboardTaskScheduleType;
|
||||
scheduleValue: string;
|
||||
contextMode: DashboardTaskContextMode;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateScheduledTaskInput {
|
||||
prompt?: string;
|
||||
scheduleType: DashboardTaskScheduleType;
|
||||
scheduleValue: string;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path} failed: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `${path} failed: ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (payload.error) message = payload.error;
|
||||
} catch {
|
||||
// Keep the status-based message when the server body is not JSON.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchDashboardData(): Promise<{
|
||||
overview: DashboardOverview;
|
||||
snapshots: StatusSnapshot[];
|
||||
tasks: DashboardTask[];
|
||||
}> {
|
||||
const [overview, snapshots, tasks] = await Promise.all([
|
||||
fetchJson<DashboardOverview>('/api/overview'),
|
||||
fetchJson<StatusSnapshot[]>('/api/status-snapshots'),
|
||||
fetchJson<DashboardTask[]>('/api/tasks'),
|
||||
]);
|
||||
|
||||
return { overview, snapshots, tasks };
|
||||
}
|
||||
|
||||
export async function fetchRoomTimeline(
|
||||
roomJid: string,
|
||||
): Promise<DashboardRoomActivity> {
|
||||
return fetchJson<DashboardRoomActivity>(
|
||||
`/api/rooms/${encodeURIComponent(roomJid)}/timeline`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchRoomsTimelineBatch(): Promise<
|
||||
Record<string, DashboardRoomActivity>
|
||||
> {
|
||||
return fetchJson<Record<string, DashboardRoomActivity>>(
|
||||
'/api/rooms-timeline',
|
||||
);
|
||||
}
|
||||
|
||||
export async function runScheduledTaskAction(
|
||||
taskId: string,
|
||||
action: DashboardTaskAction,
|
||||
): Promise<{
|
||||
ok: true;
|
||||
id?: string;
|
||||
deleted?: boolean;
|
||||
task?: DashboardTask | null;
|
||||
}> {
|
||||
return postJson(`/api/tasks/${encodeURIComponent(taskId)}/actions`, {
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createScheduledTask(
|
||||
input: CreateScheduledTaskInput,
|
||||
): Promise<{ ok: true; task: DashboardTask | null }> {
|
||||
return postJson('/api/tasks', input);
|
||||
}
|
||||
|
||||
export async function updateScheduledTask(
|
||||
taskId: string,
|
||||
input: UpdateScheduledTaskInput,
|
||||
): Promise<{ ok: true; task: DashboardTask | null }> {
|
||||
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `/api/tasks/${taskId} failed: ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (payload.error) message = payload.error;
|
||||
} catch {
|
||||
// Keep the status-based message when the server body is not JSON.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return (await response.json()) as { ok: true; task: DashboardTask | null };
|
||||
}
|
||||
|
||||
export async function runInboxAction(
|
||||
inboxId: string,
|
||||
action: DashboardInboxAction,
|
||||
options: { requestId?: string; lastOccurredAt?: string } = {},
|
||||
): Promise<{
|
||||
ok: true;
|
||||
id: string;
|
||||
taskId?: string;
|
||||
intentKind?: string;
|
||||
status?: string;
|
||||
queued?: boolean;
|
||||
dismissed?: boolean;
|
||||
duplicate?: boolean;
|
||||
}> {
|
||||
return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, {
|
||||
action,
|
||||
lastOccurredAt: options.lastOccurredAt,
|
||||
requestId: options.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runServiceAction(
|
||||
serviceId: 'stack',
|
||||
action: DashboardServiceAction,
|
||||
options: { requestId?: string } = {},
|
||||
): Promise<{
|
||||
ok: true;
|
||||
duplicate?: boolean;
|
||||
restart: DashboardServiceRestart;
|
||||
}> {
|
||||
return postJson(`/api/services/${encodeURIComponent(serviceId)}/actions`, {
|
||||
action,
|
||||
requestId: options.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendRoomMessage(
|
||||
roomJid: string,
|
||||
text: string,
|
||||
requestId: string,
|
||||
nickname?: string | null,
|
||||
): Promise<{ ok: true; id: string; queued: boolean }> {
|
||||
return postJson(`/api/rooms/${encodeURIComponent(roomJid)}/messages`, {
|
||||
requestId,
|
||||
text,
|
||||
nickname: nickname ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ClaudeAccountSummary {
|
||||
index: number;
|
||||
expiresAt: number | null;
|
||||
scopes: string[];
|
||||
subscriptionType?: string;
|
||||
rateLimitTier?: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface CodexAccountSummary {
|
||||
index: number;
|
||||
accountId: string | null;
|
||||
planType: string | null;
|
||||
subscriptionUntil: string | null;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface ModelRoleConfig {
|
||||
model: string;
|
||||
effort: string;
|
||||
}
|
||||
|
||||
export interface ModelConfigSnapshot {
|
||||
owner: ModelRoleConfig;
|
||||
reviewer: ModelRoleConfig;
|
||||
arbiter: ModelRoleConfig;
|
||||
}
|
||||
|
||||
export async function fetchAccounts(): Promise<{
|
||||
claude: ClaudeAccountSummary[];
|
||||
codex: CodexAccountSummary[];
|
||||
}> {
|
||||
return fetchJson('/api/settings/accounts');
|
||||
}
|
||||
|
||||
export async function fetchModelConfig(): Promise<ModelConfigSnapshot> {
|
||||
return fetchJson('/api/settings/models');
|
||||
}
|
||||
|
||||
export async function updateModels(
|
||||
input: Partial<{
|
||||
owner: Partial<ModelRoleConfig>;
|
||||
reviewer: Partial<ModelRoleConfig>;
|
||||
arbiter: Partial<ModelRoleConfig>;
|
||||
}>,
|
||||
): Promise<ModelConfigSnapshot> {
|
||||
const response = await fetch('/api/settings/models', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let msg = `update models failed: ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (payload.error) msg = payload.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return (await response.json()) as ModelConfigSnapshot;
|
||||
}
|
||||
|
||||
export async function deleteAccount(
|
||||
provider: 'claude' | 'codex',
|
||||
index: number,
|
||||
): Promise<{ ok: true; provider: string; index: number }> {
|
||||
const response = await fetch(`/api/settings/accounts/${provider}/${index}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
let msg = `delete account failed: ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (payload.error) msg = payload.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return (await response.json()) as {
|
||||
ok: true;
|
||||
provider: string;
|
||||
index: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function addClaudeAccount(
|
||||
token: string,
|
||||
): Promise<{ ok: true; index: number; accountId: string | null }> {
|
||||
return postJson('/api/settings/accounts/claude', { token });
|
||||
}
|
||||
1522
apps/dashboard/src/i18n.ts
Normal file
1522
apps/dashboard/src/i18n.ts
Normal file
File diff suppressed because it is too large
Load Diff
16
apps/dashboard/src/main.tsx
Normal file
16
apps/dashboard/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Dashboard root element was not found');
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
4593
apps/dashboard/src/styles.css
Normal file
4593
apps/dashboard/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/dashboard/src/vite-env.d.ts
vendored
Normal file
1
apps/dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
apps/dashboard/tsconfig.json
Normal file
21
apps/dashboard/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
18
apps/dashboard/vite.config.ts
Normal file
18
apps/dashboard/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
host: process.env.VITE_DEV_HOST ?? '127.0.0.1',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:8734',
|
||||
},
|
||||
},
|
||||
});
|
||||
322
bun.lock
322
bun.lock
@@ -9,8 +9,13 @@
|
||||
"cron-parser": "^5.5.0",
|
||||
"discord.js": "^14.18.0",
|
||||
"ejclaw-runners-shared": "file:./runners/shared",
|
||||
"lucide-react": "^1.11.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
@@ -18,11 +23,16 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"husky": "^9.1.7",
|
||||
"playwright": "^1.59.1",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.0.18",
|
||||
},
|
||||
},
|
||||
@@ -50,6 +60,12 @@
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||
@@ -112,8 +128,44 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||
@@ -172,20 +224,42 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||
@@ -216,6 +290,8 @@
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="],
|
||||
@@ -236,12 +312,24 @@
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
@@ -256,18 +344,26 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.41", "", {}, "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ=="],
|
||||
|
||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||
@@ -294,6 +390,10 @@
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
@@ -310,6 +410,8 @@
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
@@ -330,7 +432,7 @@
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -350,12 +452,18 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||
|
||||
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"husky": ["husky@9.1.7", "", { "bin": "bin.js" }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
|
||||
@@ -368,10 +476,22 @@
|
||||
|
||||
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
@@ -392,10 +512,38 @@
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.11.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
||||
@@ -406,12 +554,100 @@
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
@@ -444,6 +680,8 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -454,7 +692,7 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": "bin.js" }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
||||
|
||||
@@ -466,7 +704,11 @@
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
@@ -474,6 +716,8 @@
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
@@ -488,14 +732,30 @@
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
@@ -506,6 +766,8 @@
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -538,6 +800,8 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
@@ -548,8 +812,14 @@
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||
@@ -562,12 +832,16 @@
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
@@ -584,13 +858,29 @@
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
|
||||
|
||||
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
|
||||
|
||||
@@ -608,12 +898,34 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
|
||||
|
||||
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"vitest/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"vitest/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
}
|
||||
}
|
||||
|
||||
24
package.json
24
package.json
@@ -7,18 +7,22 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dashboard:dev": "vite --config apps/dashboard/vite.config.ts",
|
||||
"dashboard:build": "vite build --config apps/dashboard/vite.config.ts",
|
||||
"dashboard:preview": "vite preview --config apps/dashboard/vite.config.ts",
|
||||
"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: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",
|
||||
"build:all": "bun run build && bun run build:runners",
|
||||
"build:all": "bun run build && bun run dashboard:build && bun run build:runners",
|
||||
"build:runtime": "bun run build:all",
|
||||
"deploy": "git pull --ff-only && bun run build:all && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw",
|
||||
"verify:dist": "bash scripts/check-dist-fresh.sh",
|
||||
"deploy": "git pull --ff-only && bun run build:all && bun run verify:dist && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw",
|
||||
"start": "bun dist/index.js",
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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:fix": "prettier --write \"{src,setup,shared,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
|
||||
"format:check": "prettier --check \"{src,setup,shared,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
|
||||
"typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p apps/dashboard/tsconfig.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:fix": "prettier --write \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
|
||||
"format:check": "prettier --check \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
|
||||
"check": "bun run format:check && bun run typecheck:all && bun run test && bun run build:all",
|
||||
"prepare": "husky",
|
||||
"setup": "bun setup/index.ts"
|
||||
@@ -28,8 +32,13 @@
|
||||
"cron-parser": "^5.5.0",
|
||||
"discord.js": "^14.18.0",
|
||||
"ejclaw-runners-shared": "file:./runners/shared",
|
||||
"lucide-react": "^1.11.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -37,11 +46,16 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"husky": "^9.1.7",
|
||||
"playwright": "^1.59.1",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -41,3 +41,36 @@ Challenge the reviewer's reasoning. Point out logical gaps, over-engineering, sc
|
||||
- Implementation, commits, and pushes require agreement from both sides. Either can veto
|
||||
- Implement directly when it makes sense — you have full implementation authority
|
||||
- Never mention or tag the user (@username) during the owner↔reviewer loop — the system handles escalation automatically. User is only notified when all resolution paths (including arbiter) are exhausted
|
||||
|
||||
## 🔴 Workspace Branch Protocol (MANDATORY)
|
||||
|
||||
The owner workspace is managed by EJClaw's paired-room state machine. The persistent owner workspace branch name MUST remain `codex/owner/<group-folder>`. Treat this as an invariant for the whole turn, not just a cleanup step at the end. If any other branch is checked out when the next message arrives, the entire room can go BLOCKED with "branch mismatch" and need manual git recovery.
|
||||
|
||||
### Every turn, in order
|
||||
|
||||
1. **Start**: verify `git branch --show-current`. It must print `codex/owner/<group-folder>` before you modify files.
|
||||
2. **Work in place on the owner branch** by default. Do not run `git checkout -b`, `git switch -c`, or switch the persistent owner workspace to `fix/*`, `feat/*`, `codex/main-*`, or any other branch.
|
||||
3. **If a temporary branch is truly necessary**, create it in a separate worktree outside the persistent owner workspace (for example under `/tmp`) and merge/cherry-pick the finished commits back into `codex/owner/<group-folder>`. Do not leave the persistent `/owner` workspace checked out on that temporary branch.
|
||||
4. **Before you finish the turn**:
|
||||
- Confirm the invariant again: `git branch --show-current` prints `codex/owner/<group-folder>`.
|
||||
- Confirm there is no unresolved git operation: no merge conflict, no detached HEAD, no rebase/cherry-pick in progress.
|
||||
- `git status --short` should be empty unless the task intentionally requires uncommitted files for the next owner step; if so, explain that state explicitly in your status line.
|
||||
5. **Only then** emit your DONE / DONE_WITH_CONCERNS / BLOCKED / NEEDS_CONTEXT line and exit.
|
||||
|
||||
### Hard rules
|
||||
|
||||
- Never switch the persistent owner workspace itself to a `fix/*`, `feat/*`, `codex/main-*`, `codex/hotfix-*`, or detached-HEAD branch.
|
||||
- Never end a turn on any branch except `codex/owner/<group-folder>`.
|
||||
- Never end a turn with unresolved merge conflicts.
|
||||
- Never leave the workspace in detached-HEAD, rebase-in-progress, cherry-pick-in-progress, or bisect state at turn end.
|
||||
|
||||
### If you discover a mismatch at turn start
|
||||
|
||||
You landed on a feature branch because a previous turn forgot to return. Recover and continue:
|
||||
|
||||
1. `git status` — inspect dirty state.
|
||||
2. If clean and the feature branch is ahead of the owner branch with a linear history: `git checkout codex/owner/<group-folder> && git merge --ff-only <feature-branch>`.
|
||||
3. If there is dirty state you actually want to keep: commit it with a meaningful message or `git stash push -u -m "<reason>"`, then switch branches and re-apply.
|
||||
4. If the feature branch has diverged in a way that is not fast-forwardable: branch it off explicitly (`backup/<group>-recover-<timestamp>`), return to the owner branch, then merge/cherry-pick the needed commits.
|
||||
|
||||
The group folder matches the EJClaw paired-room workspace directory name (for example `eyejokerdb-9`, `ejset`, `brain`). Use `basename $(pwd | sed 's|/owner$||')` if in doubt.
|
||||
|
||||
@@ -41,6 +41,7 @@ For locally generated images or e2e screenshots that should appear in Discord, p
|
||||
}
|
||||
```
|
||||
|
||||
- When emitting this as your final runner output, emit the JSON envelope directly. Do not wrap it in Markdown fences or add prose outside the JSON.
|
||||
- Use absolute local paths only
|
||||
- Do not duplicate the same path in the visible text
|
||||
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||
|
||||
@@ -45,7 +45,7 @@ function platformCandidates(
|
||||
return [
|
||||
{
|
||||
pkg: `@anthropic-ai/claude-agent-sdk-darwin-${arch}`,
|
||||
file: arch === 'arm64' ? 'cli' : 'cli',
|
||||
file: 'claude',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -53,7 +53,7 @@ function platformCandidates(
|
||||
return [
|
||||
{
|
||||
pkg: `@anthropic-ai/claude-agent-sdk-win32-${arch}`,
|
||||
file: 'cli.exe',
|
||||
file: 'claude.exe',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -127,12 +127,22 @@ export function resolveBundledClaudeCodeExecutable(options?: {
|
||||
* Default package directory resolver. Uses `require.resolve` against this
|
||||
* module's location so it works regardless of whether agent-runner is invoked
|
||||
* from its own node_modules layout or via a parent workspace.
|
||||
*
|
||||
* Important: the SDK's optional native-binary packages may not expose a bare
|
||||
* package entrypoint, so `require.resolve(pkg)` can fail even when the package
|
||||
* and binary are installed. Resolve `package.json` first, then fall back to the
|
||||
* bare package only for package layouts that do expose an entrypoint.
|
||||
*/
|
||||
function defaultResolvePackageDir(pkg: string): string | null {
|
||||
const req = createRequire(import.meta.url);
|
||||
try {
|
||||
const pkgJson = req.resolve(`${pkg}/package.json`);
|
||||
return path.dirname(pkgJson);
|
||||
} catch {
|
||||
// Fall through to legacy/bare-entrypoint resolution below.
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve the package entrypoint instead of package.json because the SDK's
|
||||
// exports map does not expose `./package.json`.
|
||||
const req = createRequire(import.meta.url);
|
||||
const entrypoint = req.resolve(pkg);
|
||||
return path.dirname(entrypoint);
|
||||
} catch {
|
||||
@@ -142,5 +152,6 @@ function defaultResolvePackageDir(pkg: string): string | null {
|
||||
|
||||
export const __test__ = {
|
||||
platformCandidates,
|
||||
defaultResolvePackageDir,
|
||||
ENV_OVERRIDE,
|
||||
};
|
||||
|
||||
@@ -356,7 +356,7 @@ async function runQuery(
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
phase: 'intermediate',
|
||||
result: pendingProgressText,
|
||||
...normalizeStructuredOutput(pendingProgressText),
|
||||
newSessionId,
|
||||
});
|
||||
pendingProgressText = null;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveBundledClaudeCodeExecutable } from '../src/bundled-cli-path.js';
|
||||
import {
|
||||
__test__,
|
||||
resolveBundledClaudeCodeExecutable,
|
||||
} from '../src/bundled-cli-path.js';
|
||||
|
||||
describe('resolveBundledClaudeCodeExecutable', () => {
|
||||
const origEnv = process.env.EJCLAW_CLAUDE_CLI_PATH;
|
||||
@@ -83,10 +87,10 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
||||
expect(result).toBe(path.join(muslDir, 'claude'));
|
||||
});
|
||||
|
||||
it('resolves Darwin arm64 binary under `cli`', () => {
|
||||
it('resolves Darwin arm64 binary under `claude`', () => {
|
||||
const dir =
|
||||
'/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64';
|
||||
const existsSync = (p: string): boolean => p === path.join(dir, 'cli');
|
||||
const existsSync = (p: string): boolean => p === path.join(dir, 'claude');
|
||||
const result = resolveBundledClaudeCodeExecutable({
|
||||
env: {},
|
||||
existsSync,
|
||||
@@ -95,13 +99,13 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
||||
resolvePackageDir: (pkg) =>
|
||||
pkg === '@anthropic-ai/claude-agent-sdk-darwin-arm64' ? dir : null,
|
||||
});
|
||||
expect(result).toBe(path.join(dir, 'cli'));
|
||||
expect(result).toBe(path.join(dir, 'claude'));
|
||||
});
|
||||
|
||||
it('resolves Windows binary under `cli.exe`', () => {
|
||||
it('resolves Windows binary under `claude.exe`', () => {
|
||||
const dir =
|
||||
'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64';
|
||||
const binary = path.join(dir, 'cli.exe');
|
||||
const binary = path.join(dir, 'claude.exe');
|
||||
const existsSync = (p: string): boolean => p === binary;
|
||||
const result = resolveBundledClaudeCodeExecutable({
|
||||
env: {},
|
||||
@@ -114,6 +118,17 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
||||
expect(result).toBe(binary);
|
||||
});
|
||||
|
||||
it('default resolver handles SDK optional packages without a bare entrypoint', () => {
|
||||
if (process.platform !== 'linux' || process.arch !== 'x64') return;
|
||||
|
||||
const dir = __test__.defaultResolvePackageDir(
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64',
|
||||
);
|
||||
if (!dir) return;
|
||||
|
||||
expect(fs.existsSync(path.join(dir, 'claude'))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws a descriptive error when no binary is found', () => {
|
||||
const existsSync = () => false;
|
||||
expect(() =>
|
||||
@@ -127,6 +142,34 @@ describe('resolveBundledClaudeCodeExecutable', () => {
|
||||
).toThrow(/Unable to locate a bundled Claude Code CLI binary/);
|
||||
});
|
||||
|
||||
it('resolves the linux x64 optional package even when it has no JS entrypoint', () => {
|
||||
if (process.platform !== 'linux' || process.arch !== 'x64') {
|
||||
return;
|
||||
}
|
||||
|
||||
const req = createRequire(import.meta.url);
|
||||
let packageJsonPath: string;
|
||||
try {
|
||||
packageJsonPath = req.resolve(
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64/package.json',
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
req.resolve('@anthropic-ai/claude-agent-sdk-linux-x64');
|
||||
return;
|
||||
} catch {
|
||||
// This is the regression case: package.json resolves, bare package does not.
|
||||
}
|
||||
|
||||
const result = resolveBundledClaudeCodeExecutable({ env: {} });
|
||||
|
||||
expect(result).toBe(path.join(path.dirname(packageJsonPath), 'claude'));
|
||||
expect(fs.existsSync(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('end-to-end: resolves the actual bundled binary on this host when installed', () => {
|
||||
// Real-world smoke test. Some CI/host sandboxes do not install the optional
|
||||
// per-platform SDK package, so treat "package not resolvable" as a host
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "ejclaw-codex-runner",
|
||||
"dependencies": {
|
||||
"@openai/codex": "^0.120.0",
|
||||
"@openai/codex": "^0.124.0",
|
||||
"ejclaw-runners-shared": "file:../shared",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -15,19 +15,19 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@openai/codex": ["@openai/codex@0.120.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.120.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.120.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.120.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.120.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.120.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.120.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-e2P1Gya3dwsRe9IPOiswVz5JfR700u+/sWCqDc3jkqv2QViPkNiBmZoGhFnZL5jBpKakSjehC4/Fpspg70nHTw=="],
|
||||
"@openai/codex": ["@openai/codex@0.124.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.124.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.124.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.124.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.124.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.124.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.124.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-1EVAuPyAQZ8zIVMw3bPJ6a4R8ifLAZ7LGsOyknj5c2he9AFXVRCmWx12WrdZJ25wcBvOEKt1n1Zx+QAj0EVGbQ=="],
|
||||
|
||||
"@openai/codex-darwin-arm64": ["@openai/codex@0.120.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7CU+I5kBaMuoqfG3xisq0mUWzxoEHvfu34cB8a0KpBiIhAgu12fKpmYgZ4/DvRP6Wm9Fu6LJYKVF5apUHFp8nQ=="],
|
||||
"@openai/codex-darwin-arm64": ["@openai/codex@0.124.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lnuqeAdl+RjPWsqVZ8rrPskRIOZmzlyr8e5q/wFVEnMPsm9dWgjRW1PKa84UmDSQVOuz9GObF28DEHFyC4A8NA=="],
|
||||
|
||||
"@openai/codex-darwin-x64": ["@openai/codex@0.120.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-d7joNYuwrmd5iIdp/xAE5f8bZT1r82MnmU6Hzgxq3G+xClwEyhxU737ZWnstFSpnZNfxJ5zXCuFUJh4CAkHNtQ=="],
|
||||
"@openai/codex-darwin-x64": ["@openai/codex@0.124.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-Br+VlL83IOu96Urw+mkZ1PQXLsQXGAYEH6kXNt4ULuVt7qAdQY2FKYwIgLLVLl0vpMk8qWxdfdVMqhiu2uCHlg=="],
|
||||
|
||||
"@openai/codex-linux-arm64": ["@openai/codex@0.120.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-sVYY25/URlpZPtb0Q0ryLh+lcq9UTEtHAkdZKa0a/R7mAdyPuhpU9V6jWmxwiUh7s53XZOEVFoKmLfH8YIDWCQ=="],
|
||||
"@openai/codex-linux-arm64": ["@openai/codex@0.124.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXafFVQJxPfU1LSCI5afuHsF5evKAcbQpFuKou0Kl241/HG/zYHdwoWj546zH844gJgegIPAxPf36+RSkUsbbA=="],
|
||||
|
||||
"@openai/codex-linux-x64": ["@openai/codex@0.120.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-VcP9B/c/O+EFEgqoetCzvHrLfAdo8vrt09Gx1lJ8ikewctqAuJ/ozj/6wuvlz7XaaK64ib5cge01pOAeCyt2Sg=="],
|
||||
"@openai/codex-linux-x64": ["@openai/codex@0.124.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-cJ4QfQIrz2gkXVWephiGo9JDyD3qLKhvkTTLk7gI8rKd7MZpVXn9/1e7pZCQnJVA7Dk6ocA5G+sxnR78SoIejw=="],
|
||||
|
||||
"@openai/codex-win32-arm64": ["@openai/codex@0.120.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-SAaTQU1XHa1qDnmQldmbyROIY5SiaspF+Cw3ziWeeTgyAET3rWusm4ELOElx6QiY1ugYW5ZD+7AFufS2z1xtpQ=="],
|
||||
"@openai/codex-win32-arm64": ["@openai/codex@0.124.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-oDSI/CQh0kagBwWVPG9EiUBfHiY27ju3LPrjTVZg4VMpVJVNA31JTK8rHMZ6G/2gfNmOl6DMBA6+0ICxSkkshg=="],
|
||||
|
||||
"@openai/codex-win32-x64": ["@openai/codex@0.120.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-zja1GNrbHyOUTvOy5FVMa+rAYIs3m+FOS8rAXftxMEhodMmkMw2O8zcvso657SHhZR0hIEiZ6T70lcyH2YX0mQ=="],
|
||||
"@openai/codex-win32-x64": ["@openai/codex@0.124.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-t+lAwmCWwIWQKk6dKaYetRhQsmA+uDmQy1NLka1xAAv3PlNOH8iNz9Lsisr3qYkBR8Tf7tbQlt+pl9tw/A5mfA=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ejclaw-runners-shared": "file:../shared",
|
||||
"@openai/codex": "^0.120.0"
|
||||
"@openai/codex": "^0.124.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
|
||||
@@ -18,6 +18,7 @@ export type RunnerOutputVerdict =
|
||||
| 'done'
|
||||
| 'done_with_concerns'
|
||||
| 'blocked'
|
||||
| 'in_progress'
|
||||
| 'silent';
|
||||
|
||||
export type RunnerOutputVisibility = 'public' | 'silent';
|
||||
@@ -93,10 +94,20 @@ function isVisibleVerdict(
|
||||
value: unknown,
|
||||
): value is Exclude<RunnerOutputVerdict, 'silent'> {
|
||||
return (
|
||||
value === 'done' || value === 'done_with_concerns' || value === 'blocked'
|
||||
value === 'done' ||
|
||||
value === 'done_with_concerns' ||
|
||||
value === 'blocked' ||
|
||||
value === 'in_progress'
|
||||
);
|
||||
}
|
||||
|
||||
const LEADING_STRUCTURED_OUTPUT_CONTROL_RE =
|
||||
/^[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]+/u;
|
||||
|
||||
function stripLeadingStructuredOutputControls(value: string): string {
|
||||
return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart();
|
||||
}
|
||||
|
||||
function normalizeAttachments(value: unknown): RunnerOutputAttachment[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
@@ -124,6 +135,13 @@ function normalizeAttachments(value: unknown): RunnerOutputAttachment[] {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
function extractStructuredJsonCandidate(trimmed: string): string {
|
||||
const fencedJson = trimmed.match(
|
||||
/^```(?:json|JSON)?[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/,
|
||||
);
|
||||
return fencedJson?.[1]?.trim() ?? trimmed;
|
||||
}
|
||||
|
||||
export function normalizeEjclawStructuredOutput(
|
||||
result: string | null,
|
||||
): NormalizedRunnerOutput {
|
||||
@@ -131,9 +149,10 @@ export function normalizeEjclawStructuredOutput(
|
||||
return { result };
|
||||
}
|
||||
|
||||
const trimmed = result.trim();
|
||||
const trimmed = stripLeadingStructuredOutputControls(result.trim());
|
||||
const jsonCandidate = extractStructuredJsonCandidate(trimmed);
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
const parsed = JSON.parse(jsonCandidate) as {
|
||||
ejclaw?: {
|
||||
visibility?: unknown;
|
||||
text?: unknown;
|
||||
|
||||
@@ -86,6 +86,175 @@ describe('shared agent protocol helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('parses fenced public ejclaw attachments', () => {
|
||||
expect(
|
||||
normalizeEjclawStructuredOutput(`\`\`\`json
|
||||
{
|
||||
"ejclaw": {
|
||||
"visibility": "public",
|
||||
"text": "검 아이콘을 생성했습니다.",
|
||||
"verdict": "done",
|
||||
"attachments": [
|
||||
{
|
||||
"path": "/tmp/imagegen-sword.png",
|
||||
"name": "imagegen-sword.png",
|
||||
"mime": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\``),
|
||||
).toEqual({
|
||||
result: '검 아이콘을 생성했습니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: '검 아이콘을 생성했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/imagegen-sword.png',
|
||||
name: 'imagegen-sword.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses fenced ejclaw envelopes with leading invisible control characters', () => {
|
||||
expect(
|
||||
normalizeEjclawStructuredOutput(`\u2063\u2063\u2063\`\`\`json
|
||||
{
|
||||
"ejclaw": {
|
||||
"visibility": "public",
|
||||
"text": "최종 이미지를 첨부했습니다.",
|
||||
"verdict": "done",
|
||||
"attachments": [
|
||||
{
|
||||
"path": "/tmp/ejclaw-discord-image-final.png",
|
||||
"name": "final.png",
|
||||
"mime": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\``),
|
||||
).toEqual({
|
||||
result: '최종 이미지를 첨부했습니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: '최종 이미지를 첨부했습니다.',
|
||||
verdict: 'done',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/ejclaw-discord-image-final.png',
|
||||
name: 'final.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses in_progress public envelopes instead of leaking raw structured JSON', () => {
|
||||
const raw = JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: '이미지를 생성하는 중입니다.',
|
||||
verdict: 'in_progress',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/ejclaw-discord-image-draft.png',
|
||||
name: 'draft.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const normalized = normalizeEjclawStructuredOutput(raw);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
result: '이미지를 생성하는 중입니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: '이미지를 생성하는 중입니다.',
|
||||
verdict: 'in_progress',
|
||||
attachments: [
|
||||
{
|
||||
path: '/tmp/ejclaw-discord-image-draft.png',
|
||||
name: 'draft.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(normalized.result).not.toContain('"ejclaw"');
|
||||
});
|
||||
|
||||
it('parses unlabeled fenced ejclaw envelopes', () => {
|
||||
expect(
|
||||
normalizeEjclawStructuredOutput(`\`\`\`
|
||||
{"ejclaw":{"visibility":"public","text":"스크린샷입니다.","verdict":"done"}}
|
||||
\`\`\``),
|
||||
).toEqual({
|
||||
result: '스크린샷입니다.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: '스크린샷입니다.',
|
||||
verdict: 'done',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not parse mixed prose and fenced ejclaw JSON as structured output', () => {
|
||||
const raw = `설명입니다.
|
||||
|
||||
\`\`\`json
|
||||
{"ejclaw":{"visibility":"public","text":"첨부입니다.","verdict":"done"}}
|
||||
\`\`\``;
|
||||
|
||||
expect(normalizeEjclawStructuredOutput(raw)).toEqual({
|
||||
result: raw,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: raw,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when fenced JSON has no ejclaw envelope', () => {
|
||||
const raw = `\`\`\`json
|
||||
{"text":"plain json"}
|
||||
\`\`\``;
|
||||
|
||||
expect(normalizeEjclawStructuredOutput(raw)).toEqual({
|
||||
result: raw,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: raw,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when multiple fenced JSON blocks are present', () => {
|
||||
const raw = `\`\`\`json
|
||||
{"ejclaw":{"visibility":"public","text":"첫 번째","verdict":"done"}}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json
|
||||
{"ejclaw":{"visibility":"public","text":"두 번째","verdict":"done"}}
|
||||
\`\`\``;
|
||||
|
||||
expect(normalizeEjclawStructuredOutput(raw)).toEqual({
|
||||
result: raw,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: raw,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to visible raw text on invalid public verdicts', () => {
|
||||
const raw = JSON.stringify({
|
||||
ejclaw: {
|
||||
|
||||
113
scripts/check-dist-fresh.sh
Executable file
113
scripts/check-dist-fresh.sh
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-dist-fresh.sh — verify compiled dist/ outputs are at least as new as their
|
||||
# corresponding src/ .ts sources. Use this as a restart-precondition / CI guard so
|
||||
# we never ship stale compiled artifacts again (see 2026-04-23 reviewer infinite-loop
|
||||
# incident caused by a patched src/bundled-cli-path.ts with a stale dist/).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-dist-fresh.sh # checks default packages (root + agent-runner)
|
||||
# scripts/check-dist-fresh.sh DIR [DIR ...] # each DIR must contain src/ and dist/
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — all dist/ files up-to-date
|
||||
# 1 — at least one dist/ file missing or older than its src/ sibling
|
||||
# 2 — usage / environment error
|
||||
#
|
||||
# Rules per package:
|
||||
# - For every foo.ts under src/ (excluding *.test.ts, *.d.ts, __tests__/*):
|
||||
# require dist/foo.js to exist AND have mtime >= src/foo.ts
|
||||
# - Missing dist/foo.js is a hard error (build never ran for that file).
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve repo root from this script's location so the script works from any cwd.
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
PACKAGES=("$@")
|
||||
else
|
||||
PACKAGES=(
|
||||
"${REPO_ROOT}"
|
||||
"${REPO_ROOT}/runners/agent-runner"
|
||||
"${REPO_ROOT}/runners/codex-runner"
|
||||
"${REPO_ROOT}/runners/shared"
|
||||
)
|
||||
fi
|
||||
|
||||
fail=0
|
||||
checked=0
|
||||
|
||||
# Use mtime in nanoseconds (GNU stat) for precision. Fallback to seconds if %Y only.
|
||||
_mtime() {
|
||||
# $1: path
|
||||
local t
|
||||
t="$(stat -c '%Y' "$1" 2>/dev/null || true)"
|
||||
if [ -z "$t" ]; then
|
||||
# BSD stat fallback (macOS)
|
||||
t="$(stat -f '%m' "$1" 2>/dev/null || true)"
|
||||
fi
|
||||
echo "${t:-0}"
|
||||
}
|
||||
|
||||
check_package() {
|
||||
local pkg="$1"
|
||||
local src_dir="${pkg}/src"
|
||||
local dist_dir="${pkg}/dist"
|
||||
|
||||
if [ ! -d "$src_dir" ]; then
|
||||
echo "skip: ${pkg} (no src/)" >&2
|
||||
return 0
|
||||
fi
|
||||
if [ ! -d "$dist_dir" ]; then
|
||||
echo "FAIL: ${pkg} — dist/ does not exist (run build)" >&2
|
||||
fail=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
local ts dist_file src_mtime dist_mtime
|
||||
# Loop over every .ts file under src/, skipping tests & type declarations.
|
||||
while IFS= read -r -d '' ts; do
|
||||
local rel="${ts#${src_dir}/}"
|
||||
dist_file="${dist_dir}/${rel%.ts}.js"
|
||||
checked=$((checked + 1))
|
||||
|
||||
if [ ! -f "$dist_file" ]; then
|
||||
echo "FAIL: missing dist file: ${dist_file} (src: ${ts})" >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
|
||||
src_mtime="$(_mtime "$ts")"
|
||||
dist_mtime="$(_mtime "$dist_file")"
|
||||
|
||||
if [ "$dist_mtime" -lt "$src_mtime" ]; then
|
||||
echo "FAIL: stale dist: ${dist_file}" >&2
|
||||
echo " src mtime=${src_mtime} ($(date -d "@${src_mtime}" '+%F %T' 2>/dev/null || true)) ${ts}" >&2
|
||||
echo " dist mtime=${dist_mtime} ($(date -d "@${dist_mtime}" '+%F %T' 2>/dev/null || true))" >&2
|
||||
fail=1
|
||||
fi
|
||||
done < <(find "$src_dir" -type f -name '*.ts' \
|
||||
! -name '*.test.ts' \
|
||||
! -name '*.d.ts' \
|
||||
! -path '*/__tests__/*' \
|
||||
-print0)
|
||||
}
|
||||
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
if [ ! -d "$pkg" ]; then
|
||||
echo "skip: ${pkg} (directory not found)" >&2
|
||||
continue
|
||||
fi
|
||||
check_package "$pkg"
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "" >&2
|
||||
echo "dist freshness check FAILED (checked ${checked} source files)" >&2
|
||||
echo "Run: bun run build:all (or the package's build script)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "dist freshness OK (checked ${checked} source files across ${#PACKAGES[@]} package(s))"
|
||||
exit 0
|
||||
141
scripts/dash-inspect.ts
Normal file
141
scripts/dash-inspect.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Dashboard inspector via Playwright.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/dash-inspect.ts shot # full-page screenshot
|
||||
* bun scripts/dash-inspect.ts shot rooms # screenshot of #/rooms
|
||||
* bun scripts/dash-inspect.ts measure '<sel>' # bounding box + computed style
|
||||
* bun scripts/dash-inspect.ts click '<sel>' # click and screenshot after
|
||||
* bun scripts/dash-inspect.ts eval '<expr>' # evaluate JS in page
|
||||
* bun scripts/dash-inspect.ts dom '<sel>' # outerHTML of selector
|
||||
* bun scripts/dash-inspect.ts route <hash> # navigate to #/<hash>
|
||||
*
|
||||
* Output: writes to /tmp/dash-shot.png + prints data to stdout.
|
||||
*/
|
||||
|
||||
import { chromium, type BrowserContext, type Page } from 'playwright';
|
||||
|
||||
const BASE = process.env.DASH_URL ?? 'http://100.101.210.95:5174';
|
||||
const SHOT = '/tmp/dash-shot.png';
|
||||
|
||||
async function withPage<T>(
|
||||
fn: (page: Page, ctx: BrowserContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1600, height: 1000 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
return await fn(page, ctx);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function gotoRoute(page: Page, route?: string) {
|
||||
const url = route ? `${BASE}/#/${route.replace(/^#?\/?/, '')}` : BASE;
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
async function shot(page: Page) {
|
||||
await page.screenshot({ path: SHOT, fullPage: false });
|
||||
console.log(`screenshot saved: ${SHOT}`);
|
||||
}
|
||||
|
||||
async function measure(page: Page, sel: string) {
|
||||
const all = await page.$$(sel);
|
||||
if (all.length === 0) {
|
||||
console.log(`NOT FOUND: ${sel}`);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < Math.min(all.length, 6); i++) {
|
||||
const el = all[i];
|
||||
const box = await el.boundingBox();
|
||||
const style = await el.evaluate((node: object) => {
|
||||
const cs = (
|
||||
globalThis as unknown as {
|
||||
getComputedStyle: (e: object) => Record<string, string>;
|
||||
}
|
||||
).getComputedStyle(node);
|
||||
return {
|
||||
display: cs.display,
|
||||
position: cs.position,
|
||||
margin: cs.margin,
|
||||
padding: cs.padding,
|
||||
width: cs.width,
|
||||
height: cs.height,
|
||||
boxSizing: cs.boxSizing,
|
||||
gridTemplateColumns: cs.gridTemplateColumns,
|
||||
gridTemplateRows: cs.gridTemplateRows,
|
||||
};
|
||||
});
|
||||
console.log(
|
||||
`[${i}] ${sel}\n rect: ${box ? `x=${Math.round(box.x)} y=${Math.round(box.y)} w=${Math.round(box.width)} h=${Math.round(box.height)}` : 'none'}\n ${JSON.stringify(style)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function evalExpr(page: Page, expr: string) {
|
||||
const result = await page.evaluate((e) => {
|
||||
// eslint-disable-next-line no-eval
|
||||
return eval(e);
|
||||
}, expr);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
async function dom(page: Page, sel: string) {
|
||||
const el = await page.$(sel);
|
||||
if (!el) {
|
||||
console.log(`NOT FOUND: ${sel}`);
|
||||
return;
|
||||
}
|
||||
const html = await el.evaluate(
|
||||
(node: object) => (node as unknown as { outerHTML: string }).outerHTML,
|
||||
);
|
||||
console.log(
|
||||
html.length > 4000 ? `${html.slice(0, 4000)}\n…(truncated)` : html,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickAndShot(page: Page, sel: string) {
|
||||
await page.click(sel);
|
||||
await page.waitForTimeout(500);
|
||||
await shot(page);
|
||||
}
|
||||
|
||||
const [, , cmd = 'shot', ...rest] = process.argv;
|
||||
|
||||
await withPage(async (page) => {
|
||||
// Default route from rest[0] when shot/measure/etc; otherwise look for explicit `route` arg.
|
||||
const routeArg =
|
||||
cmd === 'shot' && rest[0] && !rest[0].startsWith('.') ? rest[0] : 'rooms';
|
||||
await gotoRoute(page, routeArg);
|
||||
|
||||
switch (cmd) {
|
||||
case 'shot':
|
||||
await shot(page);
|
||||
break;
|
||||
case 'measure':
|
||||
await measure(page, rest[0]);
|
||||
break;
|
||||
case 'click':
|
||||
await clickAndShot(page, rest[0]);
|
||||
break;
|
||||
case 'eval':
|
||||
await evalExpr(page, rest[0]);
|
||||
break;
|
||||
case 'dom':
|
||||
await dom(page, rest[0]);
|
||||
break;
|
||||
case 'route':
|
||||
await shot(page);
|
||||
break;
|
||||
default:
|
||||
console.error(`unknown command: ${cmd}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -243,7 +243,12 @@ describe('room registration state', () => {
|
||||
JSON.stringify({ last_timestamp: '1234' }),
|
||||
);
|
||||
|
||||
expect(detectRoomRegistrationState({ projectRoot: tempRoot })).toEqual({
|
||||
expect(
|
||||
detectRoomRegistrationState({
|
||||
projectRoot: tempRoot,
|
||||
dbPath: path.join(tempRoot, 'messages.db'),
|
||||
}),
|
||||
).toEqual({
|
||||
assignedRooms: 0,
|
||||
roomsByOwnerAgent: {},
|
||||
legacyRegisteredGroupRows: 0,
|
||||
|
||||
@@ -56,6 +56,19 @@ function createFakeAccounts(homeDir: string, count: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultCodexAuth(homeDir: string): string {
|
||||
const authPath = path.join(homeDir, '.codex', 'auth.json');
|
||||
fs.mkdirSync(path.dirname(authPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { account_id: 'default-acct', access_token: 'default-token' },
|
||||
}),
|
||||
);
|
||||
return authPath;
|
||||
}
|
||||
|
||||
describe('codex-token-rotation d7 ≥ 100% auto-skip', () => {
|
||||
let tempHome: string;
|
||||
|
||||
@@ -142,4 +155,46 @@ describe('codex-token-rotation d7 ≥ 100% auto-skip', () => {
|
||||
'Failed to persist Codex rotation state',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append ~/.codex/auth.json fallback when numbered accounts exist', async () => {
|
||||
const fallbackAuthPath = createDefaultCodexAuth(tempHome);
|
||||
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
expect(mod.getCodexAccountCount()).toBe(4);
|
||||
expect(mod.getActiveCodexAuthPath()).not.toBe(fallbackAuthPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex-token-rotation single-account fallback', () => {
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-fallback-'));
|
||||
process.env.CODEX_ROT_TEST_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CODEX_ROT_TEST_HOME;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses ~/.codex/auth.json fallback when ~/.codex-accounts is absent', async () => {
|
||||
const fallbackAuthPath = createDefaultCodexAuth(tempHome);
|
||||
|
||||
const mod = await import('./codex-token-rotation.js');
|
||||
mod.initCodexTokenRotation();
|
||||
|
||||
expect(mod.getCodexAccountCount()).toBe(1);
|
||||
expect(mod.getActiveCodexAuthPath()).toBe(fallbackAuthPath);
|
||||
expect(mod.getAllCodexAccounts()).toEqual([
|
||||
expect.objectContaining({
|
||||
index: 0,
|
||||
accountId: 'default-acct',
|
||||
isActive: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,46 +78,63 @@ let currentIndex = 0;
|
||||
let initialized = false;
|
||||
|
||||
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 loadCodexAccount(
|
||||
authPath: string,
|
||||
fallbackAccountId: string,
|
||||
): boolean {
|
||||
const data = readJsonFile<{
|
||||
tokens?: { account_id?: string; id_token?: string };
|
||||
}>(authPath);
|
||||
if (!data) {
|
||||
logger.warn({ authPath }, 'Failed to parse codex account auth.json');
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountId = data?.tokens?.account_id || fallbackAccountId;
|
||||
const jwt = parseJwtAuth(data?.tokens?.id_token || '');
|
||||
accounts.push({
|
||||
index: accounts.length,
|
||||
authPath,
|
||||
accountId,
|
||||
planType: jwt.planType,
|
||||
subscriptionUntil: jwt.expiresAt,
|
||||
rateLimitedUntil: null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function initCodexTokenRotation(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
if (!fs.existsSync(ACCOUNTS_DIR)) {
|
||||
logger.info(
|
||||
{ dir: ACCOUNTS_DIR },
|
||||
'Codex accounts dir not found, skipping',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hasAccountsDir = fs.existsSync(ACCOUNTS_DIR);
|
||||
const dirs = hasAccountsDir
|
||||
? fs
|
||||
.readdirSync(ACCOUNTS_DIR)
|
||||
.filter((d) => /^\d+$/.test(d))
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
: [];
|
||||
|
||||
const dirs = fs
|
||||
.readdirSync(ACCOUNTS_DIR)
|
||||
.filter((d) => /^\d+$/.test(d))
|
||||
.sort((a, b) => parseInt(a) - parseInt(b));
|
||||
if (!hasAccountsDir) {
|
||||
logger.info({ dir: ACCOUNTS_DIR }, 'Codex accounts dir not found');
|
||||
}
|
||||
|
||||
for (const dir of dirs) {
|
||||
const authPath = path.join(ACCOUNTS_DIR, dir, 'auth.json');
|
||||
if (!fs.existsSync(authPath)) continue;
|
||||
loadCodexAccount(authPath, `account-${dir}`);
|
||||
}
|
||||
|
||||
const data = readJsonFile<{
|
||||
tokens?: { account_id?: string; id_token?: string };
|
||||
}>(authPath);
|
||||
if (!data) {
|
||||
logger.warn({ authPath }, 'Failed to parse codex account auth.json');
|
||||
continue;
|
||||
if (dirs.length === 0 && fs.existsSync(DEFAULT_AUTH_PATH)) {
|
||||
if (loadCodexAccount(DEFAULT_AUTH_PATH, 'default-account')) {
|
||||
logger.info(
|
||||
{ authPath: DEFAULT_AUTH_PATH },
|
||||
'Codex accounts dir absent/empty; using ~/.codex/auth.json fallback',
|
||||
);
|
||||
}
|
||||
const accountId = data?.tokens?.account_id || `account-${dir}`;
|
||||
const jwt = parseJwtAuth(data?.tokens?.id_token || '');
|
||||
const planType = jwt.planType;
|
||||
accounts.push({
|
||||
index: accounts.length,
|
||||
authPath,
|
||||
accountId,
|
||||
planType,
|
||||
subscriptionUntil: jwt.expiresAt,
|
||||
rateLimitedUntil: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (accounts.length > 1) loadCodexState();
|
||||
@@ -236,8 +253,14 @@ export function reloadCodexStateFromDisk(): void {
|
||||
|
||||
/** Get the auth.json path for the current active account. */
|
||||
export function getActiveCodexAuthPath(): string | null {
|
||||
return getCodexAuthPath();
|
||||
}
|
||||
|
||||
export function getCodexAuthPath(
|
||||
accountIndex: number = currentIndex,
|
||||
): string | null {
|
||||
if (accounts.length === 0) return null;
|
||||
return accounts[currentIndex]?.authPath ?? null;
|
||||
return accounts[accountIndex]?.authPath ?? null;
|
||||
}
|
||||
|
||||
export function detectCodexRotationTrigger(
|
||||
|
||||
242
src/codex-usage-collector.test.ts
Normal file
242
src/codex-usage-collector.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/ejclaw-codex-usage-data',
|
||||
}));
|
||||
|
||||
vi.mock('./utils.js', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./utils.js')>('./utils.js');
|
||||
return {
|
||||
...actual,
|
||||
writeJsonFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
homedir: () => process.env.CODEX_USAGE_TEST_HOME || '/tmp',
|
||||
},
|
||||
homedir: () => process.env.CODEX_USAGE_TEST_HOME || '/tmp',
|
||||
};
|
||||
});
|
||||
|
||||
function createDefaultCodexAuth(homeDir: string): string {
|
||||
const authPath = path.join(homeDir, '.codex', 'auth.json');
|
||||
fs.mkdirSync(path.dirname(authPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
tokens: { account_id: 'default-acct', access_token: 'default-token' },
|
||||
}),
|
||||
);
|
||||
return authPath;
|
||||
}
|
||||
|
||||
function createFakeChildProcess(rateLimitsByLimitId: Record<string, unknown>) {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stdin: { write: ReturnType<typeof vi.fn> };
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const stdout = new EventEmitter();
|
||||
proc.stdout = stdout;
|
||||
proc.stdin = {
|
||||
write: vi.fn((payload: string) => {
|
||||
const message = JSON.parse(payload.trim()) as {
|
||||
id: number;
|
||||
method?: string;
|
||||
};
|
||||
if (message.id === 1) {
|
||||
setImmediate(() => {
|
||||
stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ id: 1, result: {} })}\n`),
|
||||
);
|
||||
});
|
||||
}
|
||||
if (message.id === 2 && message.method === 'account/rateLimits/read') {
|
||||
setImmediate(() => {
|
||||
stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
id: 2,
|
||||
result: { rateLimitsByLimitId },
|
||||
})}\n`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
proc.kill = vi.fn();
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe('codex-usage-collector fallback account usage', () => {
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-usage-'));
|
||||
process.env.CODEX_USAGE_TEST_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CODEX_USAGE_TEST_HOME;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('refreshes usage via ~/.codex/auth.json when ~/.codex-accounts is missing', async () => {
|
||||
createDefaultCodexAuth(tempHome);
|
||||
const childProcess = await import('child_process');
|
||||
const fallbackCodexHome = path.join(tempHome, '.codex');
|
||||
let capturedCodexHome: string | undefined;
|
||||
|
||||
vi.mocked(childProcess.spawn).mockImplementation(((
|
||||
_cmd: string,
|
||||
_args: readonly string[] | undefined,
|
||||
opts?: { env?: Record<string, string> },
|
||||
) => {
|
||||
capturedCodexHome = opts?.env?.CODEX_HOME;
|
||||
return createFakeChildProcess({
|
||||
codex: {
|
||||
limitName: 'Codex',
|
||||
primary: {
|
||||
usedPercent: 12.4,
|
||||
resetsAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 67.6,
|
||||
resetsAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
}) as unknown as typeof childProcess.spawn);
|
||||
|
||||
const rotation = await import('./codex-token-rotation.js');
|
||||
const usage = await import('./codex-usage-collector.js');
|
||||
|
||||
rotation.initCodexTokenRotation();
|
||||
const result = await usage.refreshActiveCodexUsage();
|
||||
|
||||
expect(rotation.getCodexAccountCount()).toBe(1);
|
||||
expect(capturedCodexHome).toBe(fallbackCodexHome);
|
||||
expect(result.fetchedAt).toEqual(expect.any(String));
|
||||
expect(result.rows).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'Codex',
|
||||
h5pct: 12,
|
||||
d7pct: 68,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds codex via ~/.hermes/node/bin when running under bun', async () => {
|
||||
createDefaultCodexAuth(tempHome);
|
||||
const childProcess = await import('child_process');
|
||||
const bunExecPath = path.join(tempHome, '.bun', 'bin', 'bun');
|
||||
const hermesBin = path.join(tempHome, '.hermes', 'node', 'bin');
|
||||
const originalExecPath = process.execPath;
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
fs.mkdirSync(hermesBin, { recursive: true });
|
||||
process.env.PATH = '/usr/local/bin:/usr/bin:/bin';
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: bunExecPath,
|
||||
});
|
||||
|
||||
vi.mocked(childProcess.spawn).mockImplementation(((
|
||||
cmd: string,
|
||||
_args: readonly string[] | undefined,
|
||||
opts?: { env?: Record<string, string> },
|
||||
) => {
|
||||
const spawnPathEntries = (opts?.env?.PATH || '').split(path.delimiter);
|
||||
expect(cmd).toBe('codex');
|
||||
expect(spawnPathEntries).toContain(path.dirname(bunExecPath));
|
||||
expect(spawnPathEntries).toContain(hermesBin);
|
||||
|
||||
if (!spawnPathEntries.includes(hermesBin)) {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stdin: { write: ReturnType<typeof vi.fn> };
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stdin = { write: vi.fn() };
|
||||
proc.kill = vi.fn();
|
||||
setImmediate(() => proc.emit('error', new Error('spawn codex ENOENT')));
|
||||
return proc as never;
|
||||
}
|
||||
|
||||
return createFakeChildProcess({
|
||||
codex: {
|
||||
limitName: 'Codex',
|
||||
primary: {
|
||||
usedPercent: 34.2,
|
||||
resetsAt: new Date(Date.now() + 7_200_000).toISOString(),
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 56.1,
|
||||
resetsAt: new Date(Date.now() + 172_800_000).toISOString(),
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
}) as unknown as typeof childProcess.spawn);
|
||||
|
||||
try {
|
||||
const rotation = await import('./codex-token-rotation.js');
|
||||
const usage = await import('./codex-usage-collector.js');
|
||||
|
||||
rotation.initCodexTokenRotation();
|
||||
const result = await usage.refreshActiveCodexUsage();
|
||||
|
||||
expect(result.fetchedAt).toEqual(expect.any(String));
|
||||
expect(result.rows).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'Codex',
|
||||
h5pct: 34,
|
||||
d7pct: 56,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalExecPath,
|
||||
});
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
|
||||
import {
|
||||
getAllCodexAccounts,
|
||||
getCodexAuthPath,
|
||||
updateCodexAccountUsage,
|
||||
} from './codex-token-rotation.js';
|
||||
import { formatResetRemaining, type UsageRow } from './dashboard-usage-rows.js';
|
||||
@@ -27,11 +28,26 @@ export interface CodexUsageRefreshResult {
|
||||
fetchedAt: string | null;
|
||||
}
|
||||
|
||||
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
|
||||
|
||||
/** Full scan interval — exported so the orchestrator can schedule it. */
|
||||
export const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour
|
||||
|
||||
function getPreferredCodexPathEntries(): string[] {
|
||||
const entries = [
|
||||
path.dirname(process.execPath),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
];
|
||||
if (process.versions.bun || path.basename(process.execPath) === 'bun') {
|
||||
entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin'));
|
||||
}
|
||||
return [...new Set(entries)];
|
||||
}
|
||||
|
||||
function getCodexHomeForAccount(accountIndex?: number): string | null {
|
||||
const authPath = getCodexAuthPath(accountIndex);
|
||||
if (!authPath || !fs.existsSync(authPath)) return null;
|
||||
return path.dirname(authPath);
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(
|
||||
codexHomeOverride?: string,
|
||||
): Promise<CodexRateLimit[] | null> {
|
||||
@@ -59,11 +75,9 @@ export async function fetchCodexUsage(
|
||||
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
PATH: [
|
||||
path.dirname(process.execPath),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
process.env.PATH || '',
|
||||
].join(':'),
|
||||
PATH: [...getPreferredCodexPathEntries(), process.env.PATH || '']
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter),
|
||||
};
|
||||
if (codexHomeOverride) {
|
||||
spawnEnv.CODEX_HOME = codexHomeOverride;
|
||||
@@ -243,8 +257,8 @@ export async function refreshAllCodexAccountUsage(): Promise<CodexUsageRefreshRe
|
||||
|
||||
let anySuccess = false;
|
||||
for (const acct of codexAccounts) {
|
||||
const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(acct.index + 1));
|
||||
if (!fs.existsSync(accountDir)) continue;
|
||||
const accountDir = getCodexHomeForAccount(acct.index);
|
||||
if (!accountDir) continue;
|
||||
|
||||
try {
|
||||
const usage = await fetchCodexUsage(accountDir);
|
||||
@@ -281,8 +295,8 @@ export async function refreshActiveCodexUsage(): Promise<CodexUsageRefreshResult
|
||||
return { rows: buildCodexUsageRowsFromState(), fetchedAt: null };
|
||||
}
|
||||
|
||||
const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(active.index + 1));
|
||||
if (!fs.existsSync(accountDir)) {
|
||||
const accountDir = getCodexHomeForAccount(active.index);
|
||||
if (!accountDir) {
|
||||
return { rows: buildCodexUsageRowsFromState(), fetchedAt: null };
|
||||
}
|
||||
|
||||
|
||||
330
src/codex-warmup.test.ts
Normal file
330
src/codex-warmup.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./codex-token-rotation.js', () => ({
|
||||
getAllCodexAccounts: vi.fn(),
|
||||
getCodexAuthPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
type FakeProcess = EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createFakeCodexProcess(exitCode = 0): FakeProcess {
|
||||
const proc = new EventEmitter() as FakeProcess;
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = { write: vi.fn(), end: vi.fn() };
|
||||
proc.kill = vi.fn();
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('OK\n'));
|
||||
proc.emit('close', exitCode, null);
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
function authPathFor(tempHome: string, accountIndex: number): string {
|
||||
return path.join(
|
||||
tempHome,
|
||||
'.codex-accounts',
|
||||
String(accountIndex + 1),
|
||||
'auth.json',
|
||||
);
|
||||
}
|
||||
|
||||
describe('Codex warm-up scheduler', () => {
|
||||
let tempHome: string;
|
||||
let statePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-warmup-'));
|
||||
statePath = path.join(tempHome, 'codex-warmup-state.json');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = authPathFor(tempHome, i);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, '{}');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('warms one eligible zero-usage account with a real codex exec and persists account cooldown state', async () => {
|
||||
const childProcess = await import('child_process');
|
||||
const rotation = await import('./codex-token-rotation.js');
|
||||
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||
const now = new Date('2026-04-24T09:00:00Z').getTime();
|
||||
let capturedEnv: Record<string, string> | undefined;
|
||||
let capturedArgs: readonly string[] | undefined;
|
||||
|
||||
vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([
|
||||
{
|
||||
index: 0,
|
||||
accountId: 'busy-account',
|
||||
planType: 'pro',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 14,
|
||||
cachedUsageD7Pct: 30,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
accountId: 'rate-limited-account',
|
||||
planType: 'pro',
|
||||
isActive: false,
|
||||
isRateLimited: true,
|
||||
cachedUsagePct: 0,
|
||||
cachedUsageD7Pct: 0,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
accountId: 'fresh-account',
|
||||
planType: 'team',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 0,
|
||||
cachedUsageD7Pct: 0,
|
||||
resetAt: '2026-04-24T14:00:00.000Z',
|
||||
resetD7At: '2026-05-01T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(rotation.getCodexAuthPath).mockImplementation(
|
||||
(accountIndex = 0) => authPathFor(tempHome, accountIndex),
|
||||
);
|
||||
vi.mocked(childProcess.spawn).mockImplementation(((
|
||||
_cmd: string,
|
||||
args?: readonly string[],
|
||||
opts?: { env?: Record<string, string> },
|
||||
) => {
|
||||
capturedArgs = args;
|
||||
capturedEnv = opts?.env;
|
||||
return createFakeCodexProcess(0) as never;
|
||||
}) as unknown as typeof childProcess.spawn);
|
||||
|
||||
const result = await runCodexWarmupCycle(
|
||||
{
|
||||
enabled: true,
|
||||
prompt: 'Reply exactly OK. Do not run tools.',
|
||||
model: 'gpt-5.5',
|
||||
intervalMs: 300_000,
|
||||
minIntervalMs: 18_300_000,
|
||||
staggerMs: 1_800_000,
|
||||
maxUsagePct: 0,
|
||||
maxD7UsagePct: 0,
|
||||
commandTimeoutMs: 120_000,
|
||||
failureCooldownMs: 21_600_000,
|
||||
maxConsecutiveFailures: 2,
|
||||
},
|
||||
{ nowMs: now, statePath },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'warmed', accountIndex: 2 });
|
||||
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
|
||||
expect(capturedArgs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'exec',
|
||||
'--ephemeral',
|
||||
'--ignore-rules',
|
||||
'--skip-git-repo-check',
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'-m',
|
||||
'gpt-5.5',
|
||||
'Reply exactly OK. Do not run tools.',
|
||||
]),
|
||||
);
|
||||
expect(capturedEnv?.CODEX_HOME).toBe(
|
||||
path.dirname(authPathFor(tempHome, 2)),
|
||||
);
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
expect(state.lastWarmupAt).toBe('2026-04-24T09:00:00.000Z');
|
||||
expect(state.accounts['2'].lastWarmupAt).toBe('2026-04-24T09:00:00.000Z');
|
||||
expect(state.accounts['2'].zeroUsageWarmupUntil).toBe(
|
||||
'2026-05-01T09:00:00.000Z',
|
||||
);
|
||||
expect(state.consecutiveFailures).toBe(0);
|
||||
});
|
||||
|
||||
it('does not repeat warm-up while the same zero-usage quota window is already marked warmed', async () => {
|
||||
const childProcess = await import('child_process');
|
||||
const rotation = await import('./codex-token-rotation.js');
|
||||
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||
const now = new Date('2026-04-24T15:00:00Z').getTime();
|
||||
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
lastWarmupAt: '2026-04-24T09:00:00.000Z',
|
||||
consecutiveFailures: 0,
|
||||
accounts: {
|
||||
'0': {
|
||||
lastWarmupAt: '2026-04-24T09:00:00.000Z',
|
||||
zeroUsageWarmupUntil: '2026-05-01T09:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([
|
||||
{
|
||||
index: 0,
|
||||
accountId: 'fresh-account-still-rounded-zero',
|
||||
planType: 'pro',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 0,
|
||||
cachedUsageD7Pct: 0,
|
||||
resetAt: '2026-04-24T14:00:00.000Z',
|
||||
resetD7At: '2026-05-01T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await runCodexWarmupCycle(
|
||||
{
|
||||
enabled: true,
|
||||
prompt: 'Reply exactly OK. Do not run tools.',
|
||||
model: 'gpt-5.5',
|
||||
intervalMs: 300_000,
|
||||
minIntervalMs: 18_300_000,
|
||||
staggerMs: 0,
|
||||
maxUsagePct: 0,
|
||||
maxD7UsagePct: 0,
|
||||
commandTimeoutMs: 120_000,
|
||||
failureCooldownMs: 21_600_000,
|
||||
maxConsecutiveFailures: 2,
|
||||
},
|
||||
{ nowMs: now, statePath },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'skipped',
|
||||
reason: 'no_eligible_accounts',
|
||||
});
|
||||
expect(childProcess.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-backs off after repeated codex exec failures so OpenAI-side blocking does not hammer accounts', async () => {
|
||||
const childProcess = await import('child_process');
|
||||
const rotation = await import('./codex-token-rotation.js');
|
||||
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||
const now = new Date('2026-04-24T09:00:00Z').getTime();
|
||||
|
||||
vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([
|
||||
{
|
||||
index: 0,
|
||||
accountId: 'fresh-account',
|
||||
planType: 'pro',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 0,
|
||||
cachedUsageD7Pct: 0,
|
||||
},
|
||||
]);
|
||||
vi.mocked(rotation.getCodexAuthPath).mockImplementation(
|
||||
(accountIndex = 0) => authPathFor(tempHome, accountIndex),
|
||||
);
|
||||
vi.mocked(childProcess.spawn).mockImplementation(
|
||||
() => createFakeCodexProcess(1) as never,
|
||||
);
|
||||
|
||||
const config = {
|
||||
enabled: true,
|
||||
prompt: 'Reply exactly OK. Do not run tools.',
|
||||
model: 'gpt-5.5',
|
||||
intervalMs: 300_000,
|
||||
minIntervalMs: 18_300_000,
|
||||
staggerMs: 0,
|
||||
maxUsagePct: 0,
|
||||
maxD7UsagePct: 0,
|
||||
commandTimeoutMs: 120_000,
|
||||
failureCooldownMs: 21_600_000,
|
||||
maxConsecutiveFailures: 1,
|
||||
};
|
||||
|
||||
const failed = await runCodexWarmupCycle(config, { nowMs: now, statePath });
|
||||
expect(failed.status).toBe('failed');
|
||||
|
||||
const backedOff = await runCodexWarmupCycle(config, {
|
||||
nowMs: now + 60_000,
|
||||
statePath,
|
||||
});
|
||||
|
||||
expect(backedOff).toEqual({
|
||||
status: 'skipped',
|
||||
reason: 'disabled_cooldown',
|
||||
});
|
||||
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
expect(state.disabledUntil).toBe('2026-04-24T15:00:00.000Z');
|
||||
expect(state.consecutiveFailures).toBe(1);
|
||||
});
|
||||
|
||||
it('respects global stagger and does not warm another account too soon', async () => {
|
||||
const childProcess = await import('child_process');
|
||||
const rotation = await import('./codex-token-rotation.js');
|
||||
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||
const now = new Date('2026-04-24T09:20:00Z').getTime();
|
||||
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
lastWarmupAt: '2026-04-24T09:00:00.000Z',
|
||||
consecutiveFailures: 0,
|
||||
accounts: { '0': { lastWarmupAt: '2026-04-24T09:00:00.000Z' } },
|
||||
}),
|
||||
);
|
||||
vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([
|
||||
{
|
||||
index: 1,
|
||||
accountId: 'another-fresh-account',
|
||||
planType: 'pro',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 0,
|
||||
cachedUsageD7Pct: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await runCodexWarmupCycle(
|
||||
{
|
||||
enabled: true,
|
||||
prompt: '.',
|
||||
model: 'gpt-5.5',
|
||||
intervalMs: 300_000,
|
||||
minIntervalMs: 18_300_000,
|
||||
staggerMs: 1_800_000,
|
||||
maxUsagePct: 0,
|
||||
maxD7UsagePct: 0,
|
||||
commandTimeoutMs: 120_000,
|
||||
failureCooldownMs: 21_600_000,
|
||||
maxConsecutiveFailures: 2,
|
||||
},
|
||||
{ nowMs: now, statePath },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'skipped', reason: 'stagger_wait' });
|
||||
expect(childProcess.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
315
src/codex-warmup.ts
Normal file
315
src/codex-warmup.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import type { AppConfig } from './config/schema.js';
|
||||
import {
|
||||
getAllCodexAccounts,
|
||||
getCodexAuthPath,
|
||||
} from './codex-token-rotation.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export type CodexWarmupConfig = AppConfig['codexWarmup'];
|
||||
|
||||
interface CodexWarmupAccountState {
|
||||
lastWarmupAt?: string;
|
||||
lastAttemptAt?: string;
|
||||
lastErrorAt?: string;
|
||||
zeroUsageWarmupUntil?: string;
|
||||
failures?: number;
|
||||
}
|
||||
|
||||
interface CodexWarmupState {
|
||||
lastWarmupAt?: string;
|
||||
lastAttemptAt?: string;
|
||||
disabledUntil?: string;
|
||||
consecutiveFailures?: number;
|
||||
accounts?: Record<string, CodexWarmupAccountState>;
|
||||
}
|
||||
|
||||
interface CodexWarmupRuntimeOptions {
|
||||
nowMs?: number;
|
||||
statePath?: string;
|
||||
shouldSkip?: () => boolean;
|
||||
}
|
||||
|
||||
export type CodexWarmupCycleResult =
|
||||
| { status: 'disabled' }
|
||||
| { status: 'skipped'; reason: string }
|
||||
| { status: 'warmed'; accountIndex: number }
|
||||
| { status: 'failed'; accountIndex: number; reason: string };
|
||||
|
||||
const DEFAULT_STATE_FILE = path.join(DATA_DIR, 'codex-warmup-state.json');
|
||||
const DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseTimestamp(value?: string): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function readWarmupState(statePath: string): CodexWarmupState {
|
||||
try {
|
||||
if (!fs.existsSync(statePath)) return { accounts: {} };
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(statePath, 'utf8'),
|
||||
) as CodexWarmupState;
|
||||
if (!parsed.accounts || typeof parsed.accounts !== 'object') {
|
||||
parsed.accounts = {};
|
||||
}
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, statePath },
|
||||
'Failed to read Codex warm-up state; starting fresh',
|
||||
);
|
||||
return { accounts: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeWarmupState(statePath: string, state: CodexWarmupState): void {
|
||||
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||
fs.writeFileSync(`${statePath}.tmp`, `${JSON.stringify(state, null, 2)}\n`);
|
||||
fs.renameSync(`${statePath}.tmp`, statePath);
|
||||
}
|
||||
|
||||
function getPreferredCodexPathEntries(): string[] {
|
||||
const entries = [
|
||||
path.dirname(process.execPath),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
];
|
||||
if (process.versions.bun || path.basename(process.execPath) === 'bun') {
|
||||
entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin'));
|
||||
}
|
||||
return [...new Set(entries)];
|
||||
}
|
||||
|
||||
function resolveCodexBinary(): string {
|
||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||
return fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
||||
}
|
||||
|
||||
function ensureAccountState(
|
||||
state: CodexWarmupState,
|
||||
accountIndex: number,
|
||||
): CodexWarmupAccountState {
|
||||
state.accounts ??= {};
|
||||
const key = String(accountIndex);
|
||||
state.accounts[key] ??= {};
|
||||
return state.accounts[key];
|
||||
}
|
||||
|
||||
function selectWarmupCandidate(
|
||||
config: CodexWarmupConfig,
|
||||
state: CodexWarmupState,
|
||||
nowMs: number,
|
||||
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
|
||||
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
||||
if (disabledUntilMs != null && disabledUntilMs > nowMs) {
|
||||
return { reason: 'disabled_cooldown' };
|
||||
}
|
||||
|
||||
const lastGlobalWarmupMs = parseTimestamp(state.lastWarmupAt);
|
||||
if (
|
||||
lastGlobalWarmupMs != null &&
|
||||
config.staggerMs > 0 &&
|
||||
nowMs - lastGlobalWarmupMs < config.staggerMs
|
||||
) {
|
||||
return { reason: 'stagger_wait' };
|
||||
}
|
||||
|
||||
const accounts = getAllCodexAccounts();
|
||||
if (accounts.length === 0) return { reason: 'no_accounts' };
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.isRateLimited) continue;
|
||||
if (typeof account.cachedUsagePct !== 'number') continue;
|
||||
if (typeof account.cachedUsageD7Pct !== 'number') continue;
|
||||
if (account.cachedUsagePct > config.maxUsagePct) continue;
|
||||
if (account.cachedUsageD7Pct > config.maxD7UsagePct) continue;
|
||||
|
||||
const accountState = state.accounts?.[String(account.index)];
|
||||
const zeroUsageWarmupUntilMs = parseTimestamp(
|
||||
accountState?.zeroUsageWarmupUntil,
|
||||
);
|
||||
if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastWarmupMs = parseTimestamp(accountState?.lastWarmupAt);
|
||||
if (lastWarmupMs != null && nowMs - lastWarmupMs < config.minIntervalMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resetD7Ms = parseTimestamp(account.resetD7At);
|
||||
const zeroUsageWarmupUntilMsForState =
|
||||
resetD7Ms != null && resetD7Ms > nowMs
|
||||
? resetD7Ms
|
||||
: nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS;
|
||||
|
||||
return {
|
||||
accountIndex: account.index,
|
||||
zeroUsageWarmupUntil: new Date(
|
||||
zeroUsageWarmupUntilMsForState,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return { reason: 'no_eligible_accounts' };
|
||||
}
|
||||
|
||||
function runCodexWarmupCommand(
|
||||
accountDir: string,
|
||||
config: CodexWarmupConfig,
|
||||
): Promise<{ ok: boolean; reason: string }> {
|
||||
const args = [
|
||||
'exec',
|
||||
'--ephemeral',
|
||||
'--ignore-rules',
|
||||
'--skip-git-repo-check',
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'-C',
|
||||
os.tmpdir(),
|
||||
'-m',
|
||||
config.model,
|
||||
config.prompt,
|
||||
];
|
||||
|
||||
const spawnEnv: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
CODEX_HOME: accountDir,
|
||||
PATH: [...getPreferredCodexPathEntries(), process.env.PATH || '']
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter),
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
let proc: ChildProcess | null = null;
|
||||
let stderr = '';
|
||||
const finish = (result: { ok: boolean; reason: string }) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
if (proc && result.reason === 'timeout') {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(
|
||||
() => finish({ ok: false, reason: 'timeout' }),
|
||||
config.commandTimeoutMs,
|
||||
);
|
||||
|
||||
try {
|
||||
proc = spawn(resolveCodexBinary(), args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: spawnEnv,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to spawn Codex warm-up command');
|
||||
finish({ ok: false, reason: 'spawn_error' });
|
||||
return;
|
||||
}
|
||||
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 2000) stderr = stderr.slice(-2000);
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
logger.warn({ err }, 'Codex warm-up process error');
|
||||
finish({ ok: false, reason: 'process_error' });
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
finish({ ok: true, reason: 'ok' });
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
{ exitCode: code, stderr: stderr.trim() || undefined },
|
||||
'Codex warm-up command failed',
|
||||
);
|
||||
finish({ ok: false, reason: `exit_${code ?? 'unknown'}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCodexWarmupCycle(
|
||||
config: CodexWarmupConfig,
|
||||
runtime: CodexWarmupRuntimeOptions = {},
|
||||
): Promise<CodexWarmupCycleResult> {
|
||||
if (!config.enabled) return { status: 'disabled' };
|
||||
if (runtime.shouldSkip?.())
|
||||
return { status: 'skipped', reason: 'runtime_busy' };
|
||||
|
||||
const nowMs = runtime.nowMs ?? Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const statePath = runtime.statePath ?? DEFAULT_STATE_FILE;
|
||||
const state = readWarmupState(statePath);
|
||||
const selected = selectWarmupCandidate(config, state, nowMs);
|
||||
if ('reason' in selected) {
|
||||
return { status: 'skipped', reason: selected.reason };
|
||||
}
|
||||
|
||||
const authPath = getCodexAuthPath(selected.accountIndex);
|
||||
if (!authPath || !fs.existsSync(authPath)) {
|
||||
return { status: 'skipped', reason: 'missing_auth' };
|
||||
}
|
||||
const accountDir = path.dirname(authPath);
|
||||
const accountState = ensureAccountState(state, selected.accountIndex);
|
||||
state.lastAttemptAt = nowIso;
|
||||
accountState.lastAttemptAt = nowIso;
|
||||
|
||||
logger.info(
|
||||
{ account: selected.accountIndex + 1, model: config.model },
|
||||
'Starting Codex warm-up prompt',
|
||||
);
|
||||
const result = await runCodexWarmupCommand(accountDir, config);
|
||||
|
||||
if (result.ok) {
|
||||
state.lastWarmupAt = nowIso;
|
||||
state.consecutiveFailures = 0;
|
||||
delete state.disabledUntil;
|
||||
accountState.lastWarmupAt = nowIso;
|
||||
accountState.zeroUsageWarmupUntil = selected.zeroUsageWarmupUntil;
|
||||
accountState.failures = 0;
|
||||
writeWarmupState(statePath, state);
|
||||
logger.info(
|
||||
{ account: selected.accountIndex + 1 },
|
||||
'Codex warm-up prompt completed',
|
||||
);
|
||||
return { status: 'warmed', accountIndex: selected.accountIndex };
|
||||
}
|
||||
|
||||
state.consecutiveFailures = (state.consecutiveFailures ?? 0) + 1;
|
||||
accountState.lastErrorAt = nowIso;
|
||||
accountState.failures = (accountState.failures ?? 0) + 1;
|
||||
if (state.consecutiveFailures >= config.maxConsecutiveFailures) {
|
||||
state.disabledUntil = new Date(
|
||||
nowMs + config.failureCooldownMs,
|
||||
).toISOString();
|
||||
}
|
||||
writeWarmupState(statePath, state);
|
||||
logger.warn(
|
||||
{
|
||||
account: selected.accountIndex + 1,
|
||||
reason: result.reason,
|
||||
consecutiveFailures: state.consecutiveFailures,
|
||||
disabledUntil: state.disabledUntil,
|
||||
},
|
||||
'Codex warm-up prompt failed',
|
||||
);
|
||||
return {
|
||||
status: 'failed',
|
||||
accountIndex: selected.accountIndex,
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,21 @@ describe('config/env loading', () => {
|
||||
delete process.env.DISCORD_CODEX_REVIEW_BOT_TOKEN;
|
||||
delete process.env.PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL;
|
||||
delete process.env.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION;
|
||||
delete process.env.CODEX_WARMUP_ENABLED;
|
||||
delete process.env.CODEX_WARMUP_PROMPT;
|
||||
delete process.env.CODEX_WARMUP_MODEL;
|
||||
delete process.env.CODEX_WARMUP_INTERVAL_MS;
|
||||
delete process.env.CODEX_WARMUP_MIN_INTERVAL_MS;
|
||||
delete process.env.CODEX_WARMUP_STAGGER_MS;
|
||||
delete process.env.CODEX_WARMUP_MAX_USAGE_PCT;
|
||||
delete process.env.CODEX_WARMUP_MAX_D7_USAGE_PCT;
|
||||
delete process.env.CODEX_WARMUP_COMMAND_TIMEOUT_MS;
|
||||
delete process.env.CODEX_WARMUP_FAILURE_COOLDOWN_MS;
|
||||
delete process.env.CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES;
|
||||
delete process.env.WEB_DASHBOARD_ENABLED;
|
||||
delete process.env.WEB_DASHBOARD_HOST;
|
||||
delete process.env.WEB_DASHBOARD_PORT;
|
||||
delete process.env.WEB_DASHBOARD_STATIC_DIR;
|
||||
delete process.env.SESSION_COMMAND_USER_IDS;
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -124,8 +139,60 @@ describe('config/env loading', () => {
|
||||
expect(config.PAIRED_FORCE_FRESH_CLAUDE_REVIEWER_SESSION).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps Codex warm-up disabled by default and exposes conservative opt-in env config', async () => {
|
||||
let config = await import('./config.js');
|
||||
expect(config.CODEX_WARMUP_CONFIG.enabled).toBe(false);
|
||||
expect(config.CODEX_WARMUP_CONFIG.maxUsagePct).toBe(0);
|
||||
expect(config.CODEX_WARMUP_CONFIG.maxD7UsagePct).toBe(0);
|
||||
expect(
|
||||
config.CODEX_WARMUP_CONFIG.maxConsecutiveFailures,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
|
||||
vi.resetModules();
|
||||
process.env.CODEX_MODEL = 'gpt-5.5';
|
||||
process.env.CODEX_WARMUP_ENABLED = 'true';
|
||||
process.env.CODEX_WARMUP_PROMPT = '.';
|
||||
process.env.CODEX_WARMUP_STAGGER_MS = '600000';
|
||||
process.env.CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES = '1';
|
||||
config = await import('./config.js');
|
||||
|
||||
expect(config.CODEX_WARMUP_CONFIG).toEqual(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
prompt: '.',
|
||||
model: 'gpt-5.5',
|
||||
staggerMs: 600000,
|
||||
maxConsecutiveFailures: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the web dashboard disabled by default and loads explicit bind/static settings', async () => {
|
||||
let config = await import('./config.js');
|
||||
expect(config.WEB_DASHBOARD.enabled).toBe(false);
|
||||
expect(config.WEB_DASHBOARD.host).toBe('127.0.0.1');
|
||||
expect(config.WEB_DASHBOARD.port).toBe(8734);
|
||||
expect(config.WEB_DASHBOARD.staticDir).toBe(
|
||||
path.resolve(tempRoot, 'apps', 'dashboard', 'dist'),
|
||||
);
|
||||
|
||||
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';
|
||||
config = await import('./config.js');
|
||||
|
||||
expect(config.WEB_DASHBOARD).toEqual({
|
||||
enabled: true,
|
||||
host: '0.0.0.0',
|
||||
port: 9001,
|
||||
staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'),
|
||||
});
|
||||
});
|
||||
|
||||
it('fails fast when a legacy Discord token alias is configured', async () => {
|
||||
process.env.DISCORD_BOT_TOKEN = 'legacy-owner-token';
|
||||
process.env.DISCORD_BOT_TOKEN = 'legacy...oken';
|
||||
|
||||
const { loadConfig } = await import('./config/load-config.js');
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ export const USAGE_UPDATE_INTERVAL = CONFIG.status.usageUpdateInterval;
|
||||
export const STATUS_SHOW_ROOMS = CONFIG.status.showRooms;
|
||||
export const STATUS_SHOW_ROOM_DETAILS = CONFIG.status.showRoomDetails;
|
||||
export const USAGE_DASHBOARD_ENABLED = CONFIG.status.usageDashboardEnabled;
|
||||
export const CODEX_WARMUP_CONFIG = CONFIG.codexWarmup;
|
||||
export const WEB_DASHBOARD = CONFIG.webDashboard;
|
||||
|
||||
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||
// Uses system timezone by default
|
||||
|
||||
@@ -53,6 +53,11 @@ function readIntegerAtLeast(
|
||||
return Math.max(minimum, readInteger(key, fallback) || fallback);
|
||||
}
|
||||
|
||||
function readPercent(key: string, fallback: number): number {
|
||||
const value = readInteger(key, fallback);
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function readAgentType(
|
||||
key: string,
|
||||
fallback?: AgentType,
|
||||
@@ -255,6 +260,49 @@ export function loadConfig(): AppConfig {
|
||||
timezone:
|
||||
readText('TZ') ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
webDashboard: {
|
||||
enabled: readBoolean('WEB_DASHBOARD_ENABLED', false),
|
||||
host: readNonEmptyText('WEB_DASHBOARD_HOST') ?? '127.0.0.1',
|
||||
port: readIntegerAtLeast('WEB_DASHBOARD_PORT', 8734, 1),
|
||||
staticDir: path.resolve(
|
||||
readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ??
|
||||
path.join(projectRoot, 'apps', 'dashboard', 'dist'),
|
||||
),
|
||||
},
|
||||
codexWarmup: {
|
||||
enabled: readBoolean('CODEX_WARMUP_ENABLED', false),
|
||||
prompt:
|
||||
readNonEmptyText('CODEX_WARMUP_PROMPT') ??
|
||||
'Reply exactly OK. Do not run tools.',
|
||||
model:
|
||||
readNonEmptyText('CODEX_WARMUP_MODEL') ??
|
||||
readNonEmptyText('CODEX_MODEL') ??
|
||||
'codex',
|
||||
intervalMs: readIntegerAtLeast('CODEX_WARMUP_INTERVAL_MS', 300000, 60000),
|
||||
minIntervalMs: readIntegerAtLeast(
|
||||
'CODEX_WARMUP_MIN_INTERVAL_MS',
|
||||
18300000,
|
||||
60000,
|
||||
),
|
||||
staggerMs: Math.max(0, readInteger('CODEX_WARMUP_STAGGER_MS', 1800000)),
|
||||
maxUsagePct: readPercent('CODEX_WARMUP_MAX_USAGE_PCT', 0),
|
||||
maxD7UsagePct: readPercent('CODEX_WARMUP_MAX_D7_USAGE_PCT', 0),
|
||||
commandTimeoutMs: readIntegerAtLeast(
|
||||
'CODEX_WARMUP_COMMAND_TIMEOUT_MS',
|
||||
120000,
|
||||
10000,
|
||||
),
|
||||
failureCooldownMs: readIntegerAtLeast(
|
||||
'CODEX_WARMUP_FAILURE_COOLDOWN_MS',
|
||||
21600000,
|
||||
60000,
|
||||
),
|
||||
maxConsecutiveFailures: readIntegerAtLeast(
|
||||
'CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES',
|
||||
2,
|
||||
1,
|
||||
),
|
||||
},
|
||||
sessionCommands: {
|
||||
allowedSenders: new Set(
|
||||
(readText('SESSION_COMMAND_ALLOWED_SENDERS') ?? '')
|
||||
|
||||
@@ -78,6 +78,25 @@ export interface AppConfig {
|
||||
usageDashboardEnabled: boolean;
|
||||
timezone: string;
|
||||
};
|
||||
webDashboard: {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
staticDir: string;
|
||||
};
|
||||
codexWarmup: {
|
||||
enabled: boolean;
|
||||
prompt: string;
|
||||
model: string;
|
||||
intervalMs: number;
|
||||
minIntervalMs: number;
|
||||
staggerMs: number;
|
||||
maxUsagePct: number;
|
||||
maxD7UsagePct: number;
|
||||
commandTimeoutMs: number;
|
||||
failureCooldownMs: number;
|
||||
maxConsecutiveFailures: number;
|
||||
};
|
||||
sessionCommands: {
|
||||
allowedSenders: Set<string>;
|
||||
};
|
||||
|
||||
43
src/data-state-files.test.ts
Normal file
43
src/data-state-files.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { listUnexpectedDataStateFiles } from './data-state-files.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-data-state-'));
|
||||
tempDirs.push(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('listUnexpectedDataStateFiles', () => {
|
||||
it('allows known runtime state files', () => {
|
||||
const dataDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(dataDir, 'token-rotation-state.json'), '{}');
|
||||
fs.writeFileSync(path.join(dataDir, 'codex-rotation-state.json'), '{}');
|
||||
fs.writeFileSync(path.join(dataDir, 'codex-warmup-state.json'), '{}');
|
||||
fs.writeFileSync(path.join(dataDir, 'claude-usage-cache.json'), '{}');
|
||||
fs.writeFileSync(path.join(dataDir, 'restart-context.abc.json'), '{}');
|
||||
|
||||
expect(listUnexpectedDataStateFiles(dataDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports unsupported legacy state files', () => {
|
||||
const dataDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(dataDir, 'router_state.json'), '{}');
|
||||
|
||||
expect(listUnexpectedDataStateFiles(dataDir)).toEqual([
|
||||
'router_state.json',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
const ALLOWED_DATA_JSON_PATTERNS = [
|
||||
/^token-rotation-state\.json$/,
|
||||
/^codex-rotation-state\.json$/,
|
||||
/^codex-warmup-state\.json$/,
|
||||
/^claude-usage-cache\.json$/,
|
||||
/^restart-context\..+\.json$/,
|
||||
];
|
||||
|
||||
157
src/db-message-source.test.ts
Normal file
157
src/db-message-source.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
_initTestDatabaseFromFile,
|
||||
getMessagesSinceSeq,
|
||||
getRecentChatMessages,
|
||||
storeChatMetadata,
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
|
||||
describe('message_source_kind persistence', () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('persists explicit message source kind through store and read paths', () => {
|
||||
storeChatMetadata('room@g.us', '2026-04-24T00:00:00.000Z', 'Room');
|
||||
storeMessage({
|
||||
id: 'ipc-1',
|
||||
chat_jid: 'room@g.us',
|
||||
sender: 'hermes',
|
||||
sender_name: 'Hermes',
|
||||
content: 'wake up',
|
||||
timestamp: '2026-04-24T00:00:01.000Z',
|
||||
is_from_me: false,
|
||||
is_bot_message: false,
|
||||
message_source_kind: 'trusted_external_bot',
|
||||
});
|
||||
|
||||
const [recent] = getRecentChatMessages('room@g.us', 1);
|
||||
expect(recent).toMatchObject({
|
||||
id: 'ipc-1',
|
||||
is_bot_message: false,
|
||||
message_source_kind: 'trusted_external_bot',
|
||||
});
|
||||
|
||||
storeMessage({
|
||||
id: 'ipc-1',
|
||||
chat_jid: 'room@g.us',
|
||||
sender: 'hermes',
|
||||
sender_name: 'Hermes',
|
||||
content: 'updated',
|
||||
timestamp: '2026-04-24T00:00:02.000Z',
|
||||
is_from_me: false,
|
||||
is_bot_message: true,
|
||||
message_source_kind: 'ipc_injected_bot',
|
||||
});
|
||||
|
||||
const [updated] = getMessagesSinceSeq('room@g.us', 0, 'EJClaw', 10);
|
||||
expect(updated).toMatchObject({
|
||||
id: 'ipc-1',
|
||||
content: 'updated',
|
||||
is_bot_message: true,
|
||||
message_source_kind: 'ipc_injected_bot',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing message source kind from is_bot_message', () => {
|
||||
storeChatMetadata('room@g.us', '2026-04-24T00:00:00.000Z', 'Room');
|
||||
storeMessage({
|
||||
id: 'human-1',
|
||||
chat_jid: 'room@g.us',
|
||||
sender: 'user',
|
||||
sender_name: 'User',
|
||||
content: 'hello',
|
||||
timestamp: '2026-04-24T00:00:01.000Z',
|
||||
is_from_me: false,
|
||||
is_bot_message: false,
|
||||
});
|
||||
storeMessage({
|
||||
id: 'bot-1',
|
||||
chat_jid: 'room@g.us',
|
||||
sender: 'bot',
|
||||
sender_name: 'Bot',
|
||||
content: 'done',
|
||||
timestamp: '2026-04-24T00:00:02.000Z',
|
||||
is_from_me: false,
|
||||
is_bot_message: true,
|
||||
});
|
||||
|
||||
expect(getRecentChatMessages('room@g.us', 2)).toEqual([
|
||||
expect.objectContaining({ id: 'human-1', message_source_kind: 'human' }),
|
||||
expect.objectContaining({ id: 'bot-1', message_source_kind: 'bot' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('migrates legacy message tables and backfills source kind', () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-msg-source-'));
|
||||
const dbPath = path.join(tempDir, 'messages.db');
|
||||
const legacyDb = new Database(dbPath);
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE chats (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_message_time TEXT,
|
||||
channel TEXT,
|
||||
is_group INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id TEXT,
|
||||
chat_jid TEXT,
|
||||
sender TEXT,
|
||||
sender_name TEXT,
|
||||
content TEXT,
|
||||
timestamp TEXT,
|
||||
seq INTEGER,
|
||||
is_from_me INTEGER,
|
||||
is_bot_message INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (id, chat_jid)
|
||||
);
|
||||
CREATE TABLE schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO chats (jid, name, last_message_time, channel, is_group)
|
||||
VALUES ('room@g.us', 'Room', '2026-04-24T00:00:02.000Z', 'discord', 1);
|
||||
INSERT INTO messages (
|
||||
id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
||||
) VALUES
|
||||
('human-legacy', 'room@g.us', 'user', 'User', 'hello', '2026-04-24T00:00:01.000Z', 1, 0, 0),
|
||||
('bot-legacy', 'room@g.us', 'bot', 'Bot', 'done', '2026-04-24T00:00:02.000Z', 2, 0, 1);
|
||||
`);
|
||||
const insertMigration = legacyDb.prepare(
|
||||
'INSERT INTO schema_migrations (version, name) VALUES (?, ?)',
|
||||
);
|
||||
for (let version = 1; version <= 12; version += 1) {
|
||||
insertMigration.run(version, `legacy-${version}`);
|
||||
}
|
||||
legacyDb.close();
|
||||
|
||||
_initTestDatabaseFromFile(dbPath);
|
||||
|
||||
expect(getRecentChatMessages('room@g.us', 2)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'human-legacy',
|
||||
message_source_kind: 'human',
|
||||
}),
|
||||
expect.objectContaining({ id: 'bot-legacy', message_source_kind: 'bot' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ export {
|
||||
getOpenWorkItem,
|
||||
getOpenWorkItemForChat,
|
||||
getRecentChatMessages,
|
||||
getRecentChatMessagesBatch,
|
||||
hasMessage,
|
||||
hasRecentRestartAnnouncement,
|
||||
markWorkItemDelivered,
|
||||
markWorkItemDeliveryRetry,
|
||||
@@ -86,6 +88,7 @@ export {
|
||||
createServiceHandoff,
|
||||
failPairedTurn,
|
||||
failServiceHandoff,
|
||||
getAllOpenPairedTasks,
|
||||
getAllChannelOwnerLeases,
|
||||
getAllPendingServiceHandoffs,
|
||||
getChannelOwnerLease,
|
||||
@@ -99,7 +102,10 @@ export {
|
||||
getPairedTurnAttempts,
|
||||
getPairedTurnById,
|
||||
getPairedTurnOutputs,
|
||||
getRecentPairedTurnOutputsForChat,
|
||||
getPairedTurnsForTask,
|
||||
getLatestPairedTurnForTask,
|
||||
updatePairedTurnProgressText,
|
||||
getPairedWorkspace,
|
||||
getPendingServiceHandoffs,
|
||||
insertPairedTurnOutput,
|
||||
|
||||
@@ -19,10 +19,12 @@ export function applyBaseSchema(database: Database): void {
|
||||
seq INTEGER,
|
||||
is_from_me INTEGER,
|
||||
is_bot_message INTEGER DEFAULT 0,
|
||||
message_source_kind TEXT NOT NULL DEFAULT 'human',
|
||||
PRIMARY KEY (id, chat_jid),
|
||||
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
|
||||
);
|
||||
|
||||
@@ -37,6 +37,9 @@ function getExpectedSchemaMigrations(): Array<{
|
||||
{ version: 10, name: 'paired_turn_provenance_upgrade' },
|
||||
{ version: 11, name: 'owner_failure_count' },
|
||||
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
|
||||
{ version: 13, name: 'message_source_kind' },
|
||||
{ version: 14, name: 'work_item_attachments' },
|
||||
{ version: 15, name: 'turn_progress_text' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import {
|
||||
inferMessageSourceKindFromBotFlag,
|
||||
normalizeMessageSourceKind,
|
||||
} from '../message-source.js';
|
||||
import { NewMessage } from '../types.js';
|
||||
|
||||
export interface ChatInfo {
|
||||
@@ -14,12 +18,18 @@ function normalizeMessageRow(
|
||||
row: NewMessage & {
|
||||
is_from_me?: boolean | number;
|
||||
is_bot_message?: boolean | number;
|
||||
message_source_kind?: unknown;
|
||||
},
|
||||
): NewMessage {
|
||||
const isBotMessage = !!row.is_bot_message;
|
||||
return {
|
||||
...row,
|
||||
is_from_me: !!row.is_from_me,
|
||||
is_bot_message: !!row.is_bot_message,
|
||||
is_bot_message: isBotMessage,
|
||||
message_source_kind: normalizeMessageSourceKind(
|
||||
row.message_source_kind,
|
||||
inferMessageSourceKindFromBotFlag(isBotMessage),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,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,
|
||||
@@ -106,15 +127,16 @@ export function storeMessageInDatabase(
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO messages (
|
||||
id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message, message_source_kind
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id, chat_jid) DO UPDATE SET
|
||||
sender = excluded.sender,
|
||||
sender_name = excluded.sender_name,
|
||||
content = excluded.content,
|
||||
timestamp = excluded.timestamp,
|
||||
is_from_me = excluded.is_from_me,
|
||||
is_bot_message = excluded.is_bot_message`,
|
||||
is_bot_message = excluded.is_bot_message,
|
||||
message_source_kind = excluded.message_source_kind`,
|
||||
)
|
||||
.run(
|
||||
msg.id,
|
||||
@@ -126,6 +148,10 @@ export function storeMessageInDatabase(
|
||||
seq,
|
||||
msg.is_from_me ? 1 : 0,
|
||||
msg.is_bot_message ? 1 : 0,
|
||||
normalizeMessageSourceKind(
|
||||
msg.message_source_kind,
|
||||
inferMessageSourceKindFromBotFlag(msg.is_bot_message),
|
||||
),
|
||||
);
|
||||
})();
|
||||
}
|
||||
@@ -142,7 +168,7 @@ export function getNewMessagesFromDatabase(
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
AND content NOT LIKE ?
|
||||
@@ -178,7 +204,7 @@ export function getMessagesSinceFromDatabase(
|
||||
): NewMessage[] {
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp > ?
|
||||
AND content NOT LIKE ?
|
||||
@@ -238,7 +264,7 @@ export function getNewMessagesBySeqFromDatabase(
|
||||
|
||||
const placeholders = jids.map(() => '?').join(',');
|
||||
const sql = `
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message, message_source_kind
|
||||
FROM messages
|
||||
WHERE seq > ? AND chat_jid IN (${placeholders})
|
||||
AND content NOT LIKE ?
|
||||
@@ -273,7 +299,7 @@ export function getMessagesSinceSeqFromDatabase(
|
||||
): NewMessage[] {
|
||||
const sinceSeq = normalizeSeqCursor(sinceSeqCursor);
|
||||
const sql = `
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message, message_source_kind
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND seq > ?
|
||||
AND content NOT LIKE ?
|
||||
@@ -293,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
|
||||
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;
|
||||
@@ -317,6 +352,44 @@ export function getRecentChatMessagesFromDatabase(
|
||||
return rows.map(normalizeMessageRow);
|
||||
}
|
||||
|
||||
export function getRecentChatMessagesBatchFromDatabase(
|
||||
database: Database,
|
||||
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
|
||||
)
|
||||
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(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
|
||||
43
src/db/migrations/013_message-source-kind.ts
Normal file
43
src/db/migrations/013_message-source-kind.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
import { tableHasColumn } from './helpers.js';
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
|
||||
export const MESSAGE_SOURCE_KIND_MIGRATION: SchemaMigrationDefinition = {
|
||||
version: 13,
|
||||
name: 'message_source_kind',
|
||||
apply(database: Database) {
|
||||
const addedColumn = !tableHasColumn(
|
||||
database,
|
||||
'messages',
|
||||
'message_source_kind',
|
||||
);
|
||||
if (addedColumn) {
|
||||
database.exec(`
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN message_source_kind TEXT NOT NULL DEFAULT 'human'
|
||||
`);
|
||||
}
|
||||
|
||||
const invalidOnlyWhere = `
|
||||
WHERE message_source_kind IS NULL
|
||||
OR message_source_kind = ''
|
||||
OR message_source_kind NOT IN (
|
||||
'human',
|
||||
'bot',
|
||||
'trusted_external_bot',
|
||||
'ipc_injected_human',
|
||||
'ipc_injected_bot'
|
||||
)
|
||||
`;
|
||||
|
||||
database.exec(`
|
||||
UPDATE messages
|
||||
SET message_source_kind = CASE
|
||||
WHEN is_bot_message = 1 THEN 'bot'
|
||||
ELSE 'human'
|
||||
END
|
||||
${addedColumn ? '' : invalidOnlyWhere}
|
||||
`);
|
||||
},
|
||||
};
|
||||
17
src/db/migrations/014_work-item-attachments.ts
Normal file
17
src/db/migrations/014_work-item-attachments.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
import { tableHasColumn } from './helpers.js';
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
|
||||
export const WORK_ITEM_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition = {
|
||||
version: 14,
|
||||
name: 'work_item_attachments',
|
||||
apply(database: Database) {
|
||||
if (!tableHasColumn(database, 'work_items', 'attachment_payload')) {
|
||||
database.exec(`
|
||||
ALTER TABLE work_items
|
||||
ADD COLUMN attachment_payload TEXT
|
||||
`);
|
||||
}
|
||||
},
|
||||
};
|
||||
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`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -12,7 +12,9 @@ import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-
|
||||
import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js';
|
||||
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 { WORK_ITEM_ATTACHMENTS_MIGRATION } from './013_work-item-attachments.js';
|
||||
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
|
||||
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
|
||||
import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
|
||||
import type {
|
||||
SchemaMigrationArgs,
|
||||
SchemaMigrationDefinition,
|
||||
@@ -33,7 +35,9 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
||||
PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION,
|
||||
OWNER_FAILURE_COUNT_MIGRATION,
|
||||
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
|
||||
MESSAGE_SOURCE_KIND_MIGRATION,
|
||||
WORK_ITEM_ATTACHMENTS_MIGRATION,
|
||||
TURN_PROGRESS_TEXT_MIGRATION,
|
||||
];
|
||||
|
||||
function ensureSchemaMigrationsTable(database: Database): void {
|
||||
|
||||
@@ -229,21 +229,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;
|
||||
}
|
||||
|
||||
@@ -266,6 +272,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,
|
||||
|
||||
@@ -56,6 +56,32 @@ export function getPairedTurnOutputsFromDatabase(
|
||||
.all(taskId) as PairedTurnOutput[];
|
||||
}
|
||||
|
||||
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 PairedTurnOutput[];
|
||||
return rows.reverse();
|
||||
}
|
||||
|
||||
export function getLatestTurnNumberFromDatabase(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
|
||||
@@ -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,56 @@ export function getPairedTurnByIdFromDatabase(
|
||||
);
|
||||
}
|
||||
|
||||
const updateProgressTextStmtCache = new WeakMap<
|
||||
Database,
|
||||
ReturnType<Database['prepare']>
|
||||
>();
|
||||
|
||||
export function updatePairedTurnProgressTextFromDatabase(
|
||||
database: Database,
|
||||
turnId: string,
|
||||
progressText: string | null,
|
||||
): void {
|
||||
let stmt = updateProgressTextStmtCache.get(database);
|
||||
if (!stmt) {
|
||||
stmt = database.prepare(`
|
||||
UPDATE paired_turns
|
||||
SET progress_text = ?, progress_updated_at = ?
|
||||
WHERE turn_id = ?
|
||||
`);
|
||||
updateProgressTextStmtCache.set(database, stmt);
|
||||
}
|
||||
stmt.run(progressText, new Date().toISOString(), 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 updated_at 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,
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
getNewMessagesBySeqFromDatabase,
|
||||
getNewMessagesFromDatabase,
|
||||
getRecentChatMessagesFromDatabase,
|
||||
getRecentChatMessagesBatchFromDatabase,
|
||||
hasMessageInDatabase,
|
||||
hasRecentRestartAnnouncementInDatabase,
|
||||
storeChatMetadataInDatabase,
|
||||
storeMessageInDatabase,
|
||||
@@ -141,6 +143,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,
|
||||
@@ -219,6 +225,17 @@ export function getRecentChatMessages(
|
||||
return getRecentChatMessagesFromDatabase(requireDatabase(), chatJid, limit);
|
||||
}
|
||||
|
||||
export function getRecentChatMessagesBatch(
|
||||
chatJids: string[],
|
||||
limit: number = 8,
|
||||
): Map<string, NewMessage[]> {
|
||||
return getRecentChatMessagesBatchFromDatabase(
|
||||
requireDatabase(),
|
||||
chatJids,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
||||
return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
clearPairedTurnReservationsInDatabase,
|
||||
type PairedTaskUpdates,
|
||||
createPairedTaskInDatabase,
|
||||
getAllOpenPairedTasksFromDatabase,
|
||||
getLastBotFinalMessageFromDatabase,
|
||||
getLatestOpenPairedTaskForChatFromDatabase,
|
||||
getLatestPreviousPairedTaskForChatFromDatabase,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
import {
|
||||
getLatestTurnNumberFromDatabase,
|
||||
getPairedTurnOutputsFromDatabase,
|
||||
getRecentPairedTurnOutputsForChatFromDatabase,
|
||||
insertPairedTurnOutputInDatabase,
|
||||
} from './paired-turn-outputs.js';
|
||||
import {
|
||||
@@ -57,6 +59,8 @@ import {
|
||||
failPairedTurnInDatabase,
|
||||
getPairedTurnByIdFromDatabase,
|
||||
getPairedTurnsForTaskFromDatabase,
|
||||
getLatestPairedTurnForTaskFromDatabase,
|
||||
updatePairedTurnProgressTextFromDatabase,
|
||||
markPairedTurnRunningInDatabase,
|
||||
type PairedTurnRecord,
|
||||
} from './paired-turns.js';
|
||||
@@ -113,6 +117,10 @@ export function getLatestPreviousPairedTaskForChat(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllOpenPairedTasks(): PairedTask[] {
|
||||
return getAllOpenPairedTasksFromDatabase(requireDatabase());
|
||||
}
|
||||
|
||||
export function updatePairedTask(id: string, updates: PairedTaskUpdates): void {
|
||||
updatePairedTaskInDatabase(requireDatabase(), id, updates);
|
||||
}
|
||||
@@ -206,6 +214,23 @@ 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[] {
|
||||
@@ -328,6 +353,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);
|
||||
}
|
||||
|
||||
61
src/index.ts
61
src/index.ts
@@ -10,6 +10,7 @@ import {
|
||||
TIMEZONE,
|
||||
TRIGGER_PATTERN,
|
||||
USAGE_UPDATE_INTERVAL,
|
||||
WEB_DASHBOARD,
|
||||
} from './config.js';
|
||||
import './channels/index.js';
|
||||
import {
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
import { createMessageRuntime } from './message-runtime.js';
|
||||
import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
|
||||
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||
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';
|
||||
@@ -61,6 +63,10 @@ import {
|
||||
hasAvailableClaudeToken,
|
||||
initTokenRotation,
|
||||
} from './token-rotation.js';
|
||||
import {
|
||||
isBotMessageSourceKind,
|
||||
resolveInjectedMessageSourceKind,
|
||||
} from './message-source.js';
|
||||
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
|
||||
|
||||
export function isTerminalStatusMessage(text: string): boolean {
|
||||
@@ -244,6 +250,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Graceful shutdown handlers
|
||||
let leaseRecoveryTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let webDashboardServer: ReturnType<typeof startWebDashboardServer> = null;
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, 'Shutdown signal received');
|
||||
stopTokenRefreshLoop();
|
||||
@@ -251,6 +258,8 @@ async function main(): Promise<void> {
|
||||
clearInterval(leaseRecoveryTimer);
|
||||
leaseRecoveryTimer = null;
|
||||
}
|
||||
webDashboardServer?.stop();
|
||||
webDashboardServer = null;
|
||||
const roomBindings = runtimeState.getRoomBindings();
|
||||
const interruptedGroups = queue
|
||||
.getStatuses(Object.keys(roomBindings))
|
||||
@@ -412,6 +421,51 @@ async function main(): Promise<void> {
|
||||
queue.noteDirectTerminalDelivery(jid, senderRole, text);
|
||||
}
|
||||
},
|
||||
injectInboundMessage: async (payload) => {
|
||||
const jid = payload.chatJid;
|
||||
const binding = runtimeState.getRoomBindings()[jid];
|
||||
if (!binding) {
|
||||
logger.warn(
|
||||
{ chatJid: jid, sender: payload.sender ?? null },
|
||||
'inject_inbound_message: no room binding, dropping',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ts = payload.timestamp || new Date().toISOString();
|
||||
const msgId =
|
||||
payload.messageId ||
|
||||
`ipc-inject-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const treatAsHuman = payload.treatAsHuman === true;
|
||||
const messageSourceKind = resolveInjectedMessageSourceKind({
|
||||
treatAsHuman,
|
||||
sourceKind: payload.sourceKind,
|
||||
});
|
||||
storeChatMetadata(jid, ts, binding.name, 'discord', true);
|
||||
storeMessage({
|
||||
id: msgId,
|
||||
chat_jid: jid,
|
||||
sender: payload.sender || 'ipc-inject',
|
||||
sender_name: payload.senderName || payload.sender || 'IPC Inject',
|
||||
content: payload.text,
|
||||
timestamp: ts,
|
||||
is_from_me: false,
|
||||
is_bot_message: isBotMessageSourceKind(messageSourceKind),
|
||||
message_source_kind: messageSourceKind,
|
||||
});
|
||||
queue.enqueueMessageCheck(jid, resolveGroupIpcPath(binding.folder));
|
||||
logger.info(
|
||||
{
|
||||
chatJid: jid,
|
||||
sender: payload.sender ?? null,
|
||||
senderName: payload.senderName ?? null,
|
||||
treatAsHuman,
|
||||
messageSourceKind,
|
||||
messageId: msgId,
|
||||
groupFolder: binding.folder,
|
||||
},
|
||||
'Injected inbound message via IPC',
|
||||
);
|
||||
},
|
||||
nudgeScheduler: nudgeSchedulerLoop,
|
||||
roomBindings: runtimeState.getRoomBindings,
|
||||
assignRoom: runtimeState.assignRoomForIpc,
|
||||
@@ -467,6 +521,13 @@ async function main(): Promise<void> {
|
||||
},
|
||||
purgeOnStart: true,
|
||||
});
|
||||
webDashboardServer = startWebDashboardServer({
|
||||
...WEB_DASHBOARD,
|
||||
getRoomBindings: runtimeState.getRoomBindings,
|
||||
enqueueMessageCheck: (chatJid, groupFolder) =>
|
||||
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
|
||||
nudgeScheduler: nudgeSchedulerLoop,
|
||||
});
|
||||
|
||||
leaseRecoveryTimer = setInterval(() => {
|
||||
const failover = getGlobalFailoverInfo();
|
||||
|
||||
@@ -119,6 +119,7 @@ interface CreateExecuteTurnDeps {
|
||||
deliveryRole: PairedRoomRole | null;
|
||||
pairedRoom: boolean;
|
||||
}) => Promise<void>;
|
||||
recordTurnProgress: (turnId: string, progressText: string) => void;
|
||||
}
|
||||
|
||||
export function createRunAgent(deps: {
|
||||
@@ -190,6 +191,8 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
|
||||
deliveryRole: resolvedDeliveryRole,
|
||||
deliveryServiceId: resolvedDeliveryServiceId,
|
||||
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
|
||||
recordTurnProgress: (turnId, progressText) =>
|
||||
deps.recordTurnProgress(turnId, progressText),
|
||||
canDeliverFinalText: () => {
|
||||
if (!args.pairedTurnIdentity) {
|
||||
return true;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getLatestOpenPairedTaskForChat,
|
||||
markWorkItemDelivered,
|
||||
getPairedTaskById,
|
||||
updatePairedTurnProgressText,
|
||||
} from './db.js';
|
||||
import {
|
||||
isSessionCommandSenderAllowed,
|
||||
@@ -262,6 +263,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
continuationTracker.open(targetChatJid),
|
||||
});
|
||||
},
|
||||
recordTurnProgress: (turnId, progressText) => {
|
||||
updatePairedTurnProgressText(turnId, progressText);
|
||||
},
|
||||
afterDeliverySuccess: async ({
|
||||
chatJid,
|
||||
runId,
|
||||
|
||||
43
src/message-source.test.ts
Normal file
43
src/message-source.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isBotMessageSourceKind,
|
||||
normalizeMessageSourceKind,
|
||||
resolveInjectedMessageSourceKind,
|
||||
} from './message-source.js';
|
||||
|
||||
describe('message source helpers', () => {
|
||||
it('defaults IPC injected messages to trusted human-equivalent provenance', () => {
|
||||
expect(resolveInjectedMessageSourceKind({ treatAsHuman: true })).toBe(
|
||||
'trusted_external_bot',
|
||||
);
|
||||
expect(
|
||||
isBotMessageSourceKind(
|
||||
resolveInjectedMessageSourceKind({ treatAsHuman: true }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults non-human IPC injected messages to bot-equivalent provenance', () => {
|
||||
expect(resolveInjectedMessageSourceKind({ treatAsHuman: false })).toBe(
|
||||
'ipc_injected_bot',
|
||||
);
|
||||
expect(
|
||||
isBotMessageSourceKind(
|
||||
resolveInjectedMessageSourceKind({ treatAsHuman: false }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('honors valid explicit source kinds and normalizes invalid values', () => {
|
||||
expect(
|
||||
resolveInjectedMessageSourceKind({
|
||||
treatAsHuman: true,
|
||||
sourceKind: 'ipc_injected_human',
|
||||
}),
|
||||
).toBe('ipc_injected_human');
|
||||
|
||||
expect(normalizeMessageSourceKind('bot', 'human')).toBe('bot');
|
||||
expect(normalizeMessageSourceKind('not-real', 'human')).toBe('human');
|
||||
});
|
||||
});
|
||||
39
src/message-source.ts
Normal file
39
src/message-source.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { MessageSourceKind } from './types.js';
|
||||
|
||||
const MESSAGE_SOURCE_KINDS = new Set<MessageSourceKind>([
|
||||
'human',
|
||||
'bot',
|
||||
'trusted_external_bot',
|
||||
'ipc_injected_human',
|
||||
'ipc_injected_bot',
|
||||
]);
|
||||
|
||||
export function normalizeMessageSourceKind(
|
||||
value: unknown,
|
||||
fallback: MessageSourceKind = 'human',
|
||||
): MessageSourceKind {
|
||||
return typeof value === 'string' &&
|
||||
MESSAGE_SOURCE_KINDS.has(value as MessageSourceKind)
|
||||
? (value as MessageSourceKind)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function isBotMessageSourceKind(kind: MessageSourceKind): boolean {
|
||||
return kind === 'bot' || kind === 'ipc_injected_bot';
|
||||
}
|
||||
|
||||
export function inferMessageSourceKindFromBotFlag(
|
||||
isBotMessage: boolean | number | null | undefined,
|
||||
): MessageSourceKind {
|
||||
return isBotMessage ? 'bot' : 'human';
|
||||
}
|
||||
|
||||
export function resolveInjectedMessageSourceKind(args: {
|
||||
treatAsHuman: boolean;
|
||||
sourceKind?: unknown;
|
||||
}): MessageSourceKind {
|
||||
return normalizeMessageSourceKind(
|
||||
args.sourceKind,
|
||||
args.treatAsHuman ? 'trusted_external_bot' : 'ipc_injected_bot',
|
||||
);
|
||||
}
|
||||
@@ -370,6 +370,71 @@ describe('MessageTurnController outbound audit logging', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends final attachments as a fresh final message instead of replacing text-only progress', async () => {
|
||||
const channel = {
|
||||
...makeChannel(),
|
||||
name: 'discord',
|
||||
} satisfies Channel;
|
||||
const deliverFinalText = vi.fn().mockResolvedValue(true);
|
||||
const attachments = [
|
||||
{
|
||||
path: '/tmp/ejclaw-discord-image-final.png',
|
||||
name: 'final.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
];
|
||||
const controller = new MessageTurnController({
|
||||
chatJid: 'dc:test-room',
|
||||
group: makeGroup(),
|
||||
runId: 'run-owner-final-attachment-send',
|
||||
channel,
|
||||
idleTimeout: 1_000,
|
||||
failureFinalText: '실패',
|
||||
isClaudeCodeAgent: true,
|
||||
clearSession: vi.fn(),
|
||||
requestClose: vi.fn(),
|
||||
deliverFinalText,
|
||||
deliveryRole: 'owner',
|
||||
pairedTurnIdentity: {
|
||||
turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
|
||||
taskId: 'task-1',
|
||||
taskUpdatedAt: '2026-04-10T14:22:00.000Z',
|
||||
intentKind: 'finalize-owner-turn',
|
||||
role: 'owner',
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await controller.handleOutput({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
result: '이미지 렌더링 중',
|
||||
} as any);
|
||||
await controller.handleOutput({
|
||||
status: 'success',
|
||||
phase: 'progress',
|
||||
result: '이미지 파일 쓰는 중',
|
||||
} as any);
|
||||
await flushAsync();
|
||||
await controller.handleOutput({
|
||||
status: 'success',
|
||||
phase: 'final',
|
||||
result: '이미지 렌더 완료.',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: '이미지 렌더 완료.',
|
||||
attachments,
|
||||
},
|
||||
} as any);
|
||||
await controller.finish('success');
|
||||
|
||||
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
|
||||
expect(deliverFinalText).toHaveBeenCalledWith('이미지 렌더 완료.', {
|
||||
replaceMessageId: null,
|
||||
attachments,
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the tracked progress message when an owner final arrives', async () => {
|
||||
const channel = {
|
||||
...makeChannel(),
|
||||
|
||||
@@ -49,6 +49,7 @@ interface MessageTurnControllerOptions {
|
||||
deliveryRole?: PairedRoomRole | null;
|
||||
deliveryServiceId?: string | null;
|
||||
pairedTurnIdentity?: PairedTurnIdentity | null;
|
||||
recordTurnProgress?: (turnId: string, progressText: string) => void;
|
||||
}
|
||||
|
||||
export class MessageTurnController {
|
||||
@@ -398,6 +399,52 @@ export class MessageTurnController {
|
||||
return this.visiblePhase === 'final';
|
||||
}
|
||||
|
||||
private composeProgressBody(text: string): string {
|
||||
if (this.subagents.size > 1) {
|
||||
const lines: string[] = [];
|
||||
for (const [, track] of this.subagents) {
|
||||
const latest = track.activities[track.activities.length - 1];
|
||||
lines.push(latest ? `${track.label} · ${latest}` : track.label);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (this.subagents.size === 1) {
|
||||
const [, track] = this.subagents.entries().next().value!;
|
||||
const lines: string[] = [track.label];
|
||||
for (let i = 0; i < track.activities.length; i++) {
|
||||
const isLast = i === track.activities.length - 1;
|
||||
lines.push(`${isLast ? '└' : '├'} ${track.activities[i]}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
const activityLines =
|
||||
this.toolActivities.length > 0
|
||||
? '\n' +
|
||||
this.toolActivities
|
||||
.map((a, i) => {
|
||||
const isLast = i === this.toolActivities.length - 1;
|
||||
const connector = isLast ? '└' : '├';
|
||||
const isSummary = a.startsWith('📋');
|
||||
return isSummary ? `${connector} ${a}` : `${connector} ${a}`;
|
||||
})
|
||||
.join('\n')
|
||||
: '';
|
||||
return text + activityLines;
|
||||
}
|
||||
|
||||
private persistProgressBody(body: string): void {
|
||||
const turnId = this.options.pairedTurnIdentity?.turnId;
|
||||
if (!turnId || !this.options.recordTurnProgress) return;
|
||||
try {
|
||||
this.options.recordTurnProgress(turnId, body);
|
||||
} catch (err) {
|
||||
this.log.warn(
|
||||
{ err, turnId, bodyLength: body.length },
|
||||
'Failed to persist progress body',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderProgressMessage(text: string): string {
|
||||
const elapsedMs =
|
||||
this.progressStartedAt === null
|
||||
@@ -405,41 +452,9 @@ export class MessageTurnController {
|
||||
: Math.floor((Date.now() - this.progressStartedAt) / 5_000) * 5000;
|
||||
|
||||
const suffix = `\n\n${formatElapsedKorean(elapsedMs)}`;
|
||||
let body: string;
|
||||
const body = this.composeProgressBody(text);
|
||||
|
||||
if (this.subagents.size > 1) {
|
||||
// Compact: one line per subagent with latest activity
|
||||
const lines: string[] = [];
|
||||
for (const [, track] of this.subagents) {
|
||||
const latest = track.activities[track.activities.length - 1];
|
||||
lines.push(latest ? `${track.label} · ${latest}` : track.label);
|
||||
}
|
||||
body = lines.join('\n');
|
||||
} else if (this.subagents.size === 1) {
|
||||
// Single subagent: detailed view with activity sub-lines
|
||||
const [, track] = this.subagents.entries().next().value!;
|
||||
const lines: string[] = [track.label];
|
||||
for (let i = 0; i < track.activities.length; i++) {
|
||||
const isLast = i === track.activities.length - 1;
|
||||
lines.push(`${isLast ? '└' : '├'} ${track.activities[i]}`);
|
||||
}
|
||||
body = lines.join('\n');
|
||||
} else {
|
||||
// Single agent rendering
|
||||
const activityLines =
|
||||
this.toolActivities.length > 0
|
||||
? '\n' +
|
||||
this.toolActivities
|
||||
.map((a, i) => {
|
||||
const isLast = i === this.toolActivities.length - 1;
|
||||
const connector = isLast ? '└' : '├';
|
||||
const isSummary = a.startsWith('📋');
|
||||
return isSummary ? `${connector} ${a}` : `${connector} ${a}`;
|
||||
})
|
||||
.join('\n')
|
||||
: '';
|
||||
body = text + activityLines;
|
||||
}
|
||||
this.persistProgressBody(body);
|
||||
|
||||
const maxBody = 2000 - TASK_STATUS_MESSAGE_PREFIX.length - suffix.length;
|
||||
const truncated =
|
||||
@@ -465,6 +480,14 @@ export class MessageTurnController {
|
||||
this.progressMessageId = null;
|
||||
this.progressStartedAt = null;
|
||||
this.progressEditFailCount = 0;
|
||||
const turnId = this.options.pairedTurnIdentity?.turnId;
|
||||
if (turnId && this.options.recordTurnProgress) {
|
||||
try {
|
||||
this.options.recordTurnProgress(turnId, '');
|
||||
} catch {
|
||||
/* clearing progress is best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -666,7 +689,10 @@ export class MessageTurnController {
|
||||
await this.flushPendingProgress(options.flushPendingText);
|
||||
}
|
||||
|
||||
const replaceMessageId = this.consumeProgressForFinalDelivery();
|
||||
const hasAttachments = (options?.attachments?.length ?? 0) > 0;
|
||||
const replaceMessageId = hasAttachments
|
||||
? this.discardProgressForAttachmentFinalDelivery()
|
||||
: this.consumeProgressForFinalDelivery();
|
||||
await this.deliverFinalText(text, {
|
||||
...(options?.attachments?.length
|
||||
? { attachments: options.attachments }
|
||||
@@ -690,6 +716,20 @@ export class MessageTurnController {
|
||||
return replaceMessageId;
|
||||
}
|
||||
|
||||
private discardProgressForAttachmentFinalDelivery(): null {
|
||||
this.log.info(
|
||||
{
|
||||
progressMessageId: this.progressMessageId,
|
||||
latestProgressText: this.latestProgressText,
|
||||
},
|
||||
this.progressMessageId
|
||||
? 'Discarding tracked progress replacement for final attachment delivery'
|
||||
: 'Delivering final attachment output without a tracked progress message to replace',
|
||||
);
|
||||
this.resetProgressState();
|
||||
return null;
|
||||
}
|
||||
|
||||
private async deliverFinalText(
|
||||
text: string,
|
||||
options?: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
@@ -16,6 +17,10 @@ export interface ValidateOutboundAttachmentsResult {
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||
const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [
|
||||
'ejclaw-attachment-',
|
||||
'ejclaw-discord-image-',
|
||||
] as const;
|
||||
|
||||
function unique(values: Array<string | null | undefined>): string[] {
|
||||
return [
|
||||
@@ -38,8 +43,9 @@ export function getDefaultAttachmentBaseDirs(): string[] {
|
||||
process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null);
|
||||
// Keep defaults narrow. Runtime-specific workspaces must be passed via
|
||||
// attachmentBaseDirs so one room cannot attach another room's files by path.
|
||||
// Ad-hoc generated files under os.tmpdir()/ejclaw-attachment-* are handled
|
||||
// separately after resolving symlinks, without allowlisting all of /tmp.
|
||||
return unique([
|
||||
'/tmp',
|
||||
path.join(DATA_DIR, 'attachments'),
|
||||
codexHome ? path.join(codexHome, 'generated_images') : null,
|
||||
])
|
||||
@@ -59,6 +65,24 @@ function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean {
|
||||
return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir));
|
||||
}
|
||||
|
||||
function isWithinDefaultTempAttachmentDir(
|
||||
realPath: string,
|
||||
tempDir: string | null,
|
||||
): boolean {
|
||||
if (!tempDir) return false;
|
||||
const relative = path.relative(tempDir, realPath);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
const [firstSegment, ...rest] = relative.split(path.sep);
|
||||
return (
|
||||
rest.length > 0 &&
|
||||
DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES.some((prefix) =>
|
||||
firstSegment.startsWith(prefix),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function detectImageMime(filePath: string): string | null {
|
||||
const handle = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
@@ -114,6 +138,7 @@ export function validateOutboundAttachments(
|
||||
...getDefaultAttachmentBaseDirs(),
|
||||
...(options.baseDirs ?? []).map(resolveExistingDir),
|
||||
]).filter((dir): dir is string => Boolean(dir));
|
||||
const defaultTempDir = resolveExistingDir(os.tmpdir());
|
||||
const files: ValidatedOutboundAttachment[] = [];
|
||||
const rejected: Array<{ path: string; reason: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -144,7 +169,10 @@ export function validateOutboundAttachments(
|
||||
rejected.push({ path: requestedPath, reason: 'too-large' });
|
||||
continue;
|
||||
}
|
||||
if (!matchesAllowedBaseDir(realPath, baseDirs)) {
|
||||
if (
|
||||
!matchesAllowedBaseDir(realPath, baseDirs) &&
|
||||
!isWithinDefaultTempAttachmentDir(realPath, defaultTempDir)
|
||||
) {
|
||||
rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -802,6 +802,42 @@ describe('paired execution context', () => {
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-anchors the owner workspace before handling a successful owner completion', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
status: 'active',
|
||||
round_trip_count: 1,
|
||||
}),
|
||||
);
|
||||
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
|
||||
{
|
||||
ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'),
|
||||
reviewerWorkspace: buildWorkspace(
|
||||
'reviewer',
|
||||
'/tmp/paired/task-1/reviewer',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: 'task-1',
|
||||
role: 'owner',
|
||||
status: 'succeeded',
|
||||
summary: 'STEP_DONE\n1단계 완료, 후속 작업 계속',
|
||||
});
|
||||
|
||||
expect(
|
||||
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
|
||||
).toHaveBeenCalledWith('task-1');
|
||||
const reanchorCallOrder = vi.mocked(
|
||||
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
|
||||
).mock.invocationCallOrder[0];
|
||||
const reviewReadyCallOrder = vi.mocked(
|
||||
pairedWorkspaceManager.markPairedTaskReviewReady,
|
||||
).mock.invocationCallOrder[0];
|
||||
expect(reanchorCallOrder).toBeLessThan(reviewReadyCallOrder);
|
||||
});
|
||||
|
||||
it('requests review when the owner reports STEP_DONE in active mode', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
|
||||
@@ -676,6 +676,27 @@ export function completePairedExecutionContext(args: {
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
try {
|
||||
provisionOwnerWorkspaceForPairedTask(taskId);
|
||||
} catch (error) {
|
||||
if (isOwnerWorkspaceRepairNeededError(error)) {
|
||||
logger.warn(
|
||||
{
|
||||
taskId,
|
||||
role,
|
||||
repairMessage: error.blockMessage || error.message,
|
||||
},
|
||||
'Owner workspace post-run guard blocked completion handling',
|
||||
);
|
||||
handleFailedOwnerExecution({
|
||||
task,
|
||||
taskId,
|
||||
summary: error.blockMessage || error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
handleOwnerCompletion({ task, taskId, summary: args.summary });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1145,7 +1145,7 @@ describe('paired workspace manager', () => {
|
||||
).toBe(ownerBranchName('named-existing-room'));
|
||||
});
|
||||
|
||||
it('blocks provisioning when a named owner workspace branch mismatch has local changes', async () => {
|
||||
it('re-anchors a dirty named owner workspace without touching local changes', async () => {
|
||||
const { db, manager } = await loadModules();
|
||||
db._initTestDatabase();
|
||||
|
||||
@@ -1156,6 +1156,10 @@ describe('paired workspace manager', () => {
|
||||
groupFolder: 'dirty-named-room',
|
||||
});
|
||||
|
||||
runGit(
|
||||
['branch', ownerBranchName('dirty-named-room'), 'HEAD'],
|
||||
canonicalDir,
|
||||
);
|
||||
const workspaceDir = ownerWorkspacePath('dirty-named-room');
|
||||
fs.mkdirSync(path.dirname(workspaceDir), { recursive: true });
|
||||
runGit(
|
||||
@@ -1173,14 +1177,90 @@ describe('paired workspace manager', () => {
|
||||
path.join(workspaceDir, 'README.md'),
|
||||
'dirty named branch\n',
|
||||
);
|
||||
fs.writeFileSync(path.join(workspaceDir, 'NOTES.md'), 'untracked keep\n');
|
||||
const dirtyBefore = runGit(['status', '--short'], workspaceDir);
|
||||
|
||||
expect(() =>
|
||||
manager.provisionOwnerWorkspaceForPairedTask(
|
||||
'paired-task-dirty-named-branch',
|
||||
const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask(
|
||||
'paired-task-dirty-named-branch',
|
||||
);
|
||||
|
||||
expect(
|
||||
runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir),
|
||||
).toBe(ownerBranchName('dirty-named-room'));
|
||||
expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toBe(
|
||||
dirtyBefore,
|
||||
);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||
'utf-8',
|
||||
),
|
||||
).toThrow(/Owner workspace needs repair/);
|
||||
expect(runGit(['branch', '--show-current'], workspaceDir)).toBe(
|
||||
'codex/owner/dirty-named-room-sync',
|
||||
).toBe('dirty named branch\n');
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(ownerWorkspace.workspace_dir, 'NOTES.md'),
|
||||
'utf-8',
|
||||
),
|
||||
).toBe('untracked keep\n');
|
||||
expect(
|
||||
runGit(['branch', '--list', 'backup/dirty-named-room-*'], canonicalDir),
|
||||
).toContain('backup/dirty-named-room-current-pre-reanchor-');
|
||||
});
|
||||
|
||||
it('re-anchors a clean divergent named owner workspace to the current feature branch head', async () => {
|
||||
const { db, manager } = await loadModules();
|
||||
db._initTestDatabase();
|
||||
|
||||
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||
initCanonicalRepo(canonicalDir);
|
||||
seedPairedTask(db, canonicalDir, {
|
||||
taskId: 'paired-task-divergent-named-branch',
|
||||
groupFolder: 'divergent-named-room',
|
||||
});
|
||||
|
||||
runGit(
|
||||
['branch', ownerBranchName('divergent-named-room'), 'HEAD'],
|
||||
canonicalDir,
|
||||
);
|
||||
const workspaceDir = ownerWorkspacePath('divergent-named-room');
|
||||
fs.mkdirSync(path.dirname(workspaceDir), { recursive: true });
|
||||
runGit(
|
||||
[
|
||||
'worktree',
|
||||
'add',
|
||||
'-b',
|
||||
'codex/feature-divergent-named-room',
|
||||
workspaceDir,
|
||||
'HEAD',
|
||||
],
|
||||
canonicalDir,
|
||||
);
|
||||
fs.writeFileSync(path.join(workspaceDir, 'README.md'), 'feature head\n');
|
||||
runGit(['add', 'README.md'], workspaceDir);
|
||||
runGit(['commit', '-m', 'feature work'], workspaceDir);
|
||||
const featureHead = runGit(['rev-parse', 'HEAD'], workspaceDir);
|
||||
|
||||
const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask(
|
||||
'paired-task-divergent-named-branch',
|
||||
);
|
||||
|
||||
expect(
|
||||
runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir),
|
||||
).toBe(ownerBranchName('divergent-named-room'));
|
||||
expect(
|
||||
runGit(
|
||||
['rev-parse', ownerBranchName('divergent-named-room')],
|
||||
canonicalDir,
|
||||
),
|
||||
).toBe(featureHead);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||
'utf-8',
|
||||
),
|
||||
).toBe('feature head\n');
|
||||
expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,74 @@ Workspace: \`${args.workspaceDir}\`
|
||||
Repair the owner workspace branch, then retry the task.`;
|
||||
}
|
||||
|
||||
function buildOwnerReanchorBackupPrefix(targetBranch: string): string {
|
||||
const groupFolder = targetBranch.startsWith('codex/owner/')
|
||||
? targetBranch.slice('codex/owner/'.length)
|
||||
: targetBranch.replace(/\//g, '-');
|
||||
return `backup/${groupFolder}`;
|
||||
}
|
||||
|
||||
function buildOwnerReanchorBackupSuffix(): string {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:.TZ]/g, '')
|
||||
.slice(0, 14);
|
||||
return `${timestamp}-${crypto.randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
|
||||
function reanchorNamedOwnerWorkspaceBranch(args: {
|
||||
canonicalWorkDir: string;
|
||||
workspaceDir: string;
|
||||
currentBranch: string;
|
||||
targetBranch: string;
|
||||
targetBranchCommit: string | null;
|
||||
currentHeadCommit: string;
|
||||
reason: string;
|
||||
}): boolean {
|
||||
const {
|
||||
canonicalWorkDir,
|
||||
workspaceDir,
|
||||
currentBranch,
|
||||
targetBranch,
|
||||
targetBranchCommit,
|
||||
currentHeadCommit,
|
||||
reason,
|
||||
} = args;
|
||||
|
||||
ensureBranchNotCheckedOutElsewhere(
|
||||
canonicalWorkDir,
|
||||
workspaceDir,
|
||||
targetBranch,
|
||||
);
|
||||
|
||||
const backupPrefix = buildOwnerReanchorBackupPrefix(targetBranch);
|
||||
const backupSuffix = buildOwnerReanchorBackupSuffix();
|
||||
const currentBackupBranch = `${backupPrefix}-current-pre-reanchor-${backupSuffix}`;
|
||||
const targetBackupBranch = `${backupPrefix}-target-pre-reanchor-${backupSuffix}`;
|
||||
|
||||
runGit(['branch', currentBackupBranch, currentBranch], workspaceDir);
|
||||
if (targetBranchCommit) {
|
||||
runGit(['branch', targetBackupBranch, targetBranch], workspaceDir);
|
||||
}
|
||||
runGit(['branch', '-f', targetBranch, currentHeadCommit], workspaceDir);
|
||||
runGit(['symbolic-ref', 'HEAD', branchRefName(targetBranch)], workspaceDir);
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
workspaceDir,
|
||||
previousBranch: currentBranch,
|
||||
targetBranch,
|
||||
currentHeadCommit,
|
||||
targetBranchCommit,
|
||||
currentBackupBranch,
|
||||
targetBackupBranch: targetBranchCommit ? targetBackupBranch : null,
|
||||
reason,
|
||||
},
|
||||
'Re-anchored owner workspace branch mismatch while preserving worktree state',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeRepairNamedOwnerWorkspaceBranch(args: {
|
||||
canonicalWorkDir: string;
|
||||
workspaceDir: string;
|
||||
@@ -174,32 +242,39 @@ function maybeRepairNamedOwnerWorkspaceBranch(args: {
|
||||
}
|
||||
|
||||
if (!isGitWorktreeClean(workspaceDir)) {
|
||||
throw new OwnerWorkspaceRepairNeededError(
|
||||
buildOwnerWorkspaceRepairBlockMessage({
|
||||
workspaceDir,
|
||||
currentBranch,
|
||||
targetBranch,
|
||||
reason:
|
||||
'Automatic repair was skipped because the workspace has local changes.',
|
||||
}),
|
||||
);
|
||||
return reanchorNamedOwnerWorkspaceBranch({
|
||||
canonicalWorkDir,
|
||||
workspaceDir,
|
||||
currentBranch,
|
||||
targetBranch,
|
||||
targetBranchCommit,
|
||||
currentHeadCommit,
|
||||
reason: 'workspace has local changes',
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetBranchCommit) {
|
||||
runGit(['switch', '-c', targetBranch], workspaceDir);
|
||||
return true;
|
||||
return reanchorNamedOwnerWorkspaceBranch({
|
||||
canonicalWorkDir,
|
||||
workspaceDir,
|
||||
currentBranch,
|
||||
targetBranch,
|
||||
targetBranchCommit,
|
||||
currentHeadCommit,
|
||||
reason: 'expected branch does not exist yet',
|
||||
});
|
||||
}
|
||||
|
||||
if (targetBranchCommit !== currentHeadCommit) {
|
||||
throw new OwnerWorkspaceRepairNeededError(
|
||||
buildOwnerWorkspaceRepairBlockMessage({
|
||||
workspaceDir,
|
||||
currentBranch,
|
||||
targetBranch,
|
||||
reason:
|
||||
'Automatic repair was skipped because the expected branch points at a different commit.',
|
||||
}),
|
||||
);
|
||||
return reanchorNamedOwnerWorkspaceBranch({
|
||||
canonicalWorkDir,
|
||||
workspaceDir,
|
||||
currentBranch,
|
||||
targetBranch,
|
||||
targetBranchCommit,
|
||||
currentHeadCommit,
|
||||
reason: 'expected branch points at a different commit',
|
||||
});
|
||||
}
|
||||
|
||||
ensureBranchNotCheckedOutElsewhere(
|
||||
|
||||
330
src/settings-store.ts
Normal file
330
src/settings-store.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Settings store for the web dashboard:
|
||||
* - lists Claude / Codex accounts (safe metadata only, no tokens)
|
||||
* - reads/writes model role configuration via .env
|
||||
* - removes account directories on the filesystem
|
||||
*
|
||||
* Account directory layout (existing convention):
|
||||
* Claude default: ~/.claude/.credentials.json
|
||||
* Claude index N≥1: ~/.claude-accounts/{N}/.credentials.json
|
||||
* Codex default: ~/.codex/auth.json
|
||||
* Codex index N≥1: ~/.codex-accounts/{N}/auth.json
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ClaudeAccountSummary {
|
||||
index: number;
|
||||
expiresAt: number | null;
|
||||
scopes: string[];
|
||||
subscriptionType?: string;
|
||||
rateLimitTier?: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface CodexAccountSummary {
|
||||
index: number;
|
||||
accountId: string | null;
|
||||
planType: string | null;
|
||||
subscriptionUntil: string | null;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface ModelRoleConfig {
|
||||
model: string;
|
||||
effort: string;
|
||||
}
|
||||
|
||||
export interface ModelConfigSnapshot {
|
||||
owner: ModelRoleConfig;
|
||||
reviewer: ModelRoleConfig;
|
||||
arbiter: ModelRoleConfig;
|
||||
}
|
||||
|
||||
const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const;
|
||||
type RoleKey = (typeof ROLE_KEYS)[number];
|
||||
|
||||
function envFilePath(): string {
|
||||
return path.join(process.cwd(), '.env');
|
||||
}
|
||||
|
||||
function readJson<T>(file: string): T | null {
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8')) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function claudeCredsPath(index: number): string {
|
||||
if (index === 0) {
|
||||
return path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
}
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
'.claude-accounts',
|
||||
String(index),
|
||||
'.credentials.json',
|
||||
);
|
||||
}
|
||||
|
||||
function codexAuthPath(index: number): string {
|
||||
if (index === 0) {
|
||||
return path.join(os.homedir(), '.codex', 'auth.json');
|
||||
}
|
||||
return path.join(os.homedir(), '.codex-accounts', String(index), 'auth.json');
|
||||
}
|
||||
|
||||
function readClaudeAccount(index: number): ClaudeAccountSummary | null {
|
||||
const file = claudeCredsPath(index);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const data = readJson<{ claudeAiOauth?: Record<string, unknown> }>(file);
|
||||
const oauth = data?.claudeAiOauth ?? {};
|
||||
return {
|
||||
index,
|
||||
expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : null,
|
||||
scopes: Array.isArray(oauth.scopes) ? (oauth.scopes as string[]) : [],
|
||||
subscriptionType:
|
||||
typeof oauth.subscriptionType === 'string'
|
||||
? oauth.subscriptionType
|
||||
: undefined,
|
||||
rateLimitTier:
|
||||
typeof oauth.rateLimitTier === 'string' ? oauth.rateLimitTier : undefined,
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
const file = codexAuthPath(index);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const data = readJson<{
|
||||
OPENAI_API_KEY?: string;
|
||||
tokens?: { id_token?: string; access_token?: string };
|
||||
}>(file);
|
||||
let accountId: string | null = null;
|
||||
let planType: string | null = null;
|
||||
let subscriptionUntil: string | null = null;
|
||||
const idToken = data?.tokens?.id_token;
|
||||
if (idToken && typeof idToken === 'string') {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(parts[1], 'base64').toString('utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
if (typeof payload.sub === 'string') accountId = payload.sub;
|
||||
const auth = payload['https://api.openai.com/auth'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (auth) {
|
||||
if (typeof auth.chatgpt_plan_type === 'string') {
|
||||
planType = auth.chatgpt_plan_type;
|
||||
}
|
||||
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
||||
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
index,
|
||||
accountId,
|
||||
planType,
|
||||
subscriptionUntil,
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function listClaudeAccounts(): ClaudeAccountSummary[] {
|
||||
const out: ClaudeAccountSummary[] = [];
|
||||
const def = readClaudeAccount(0);
|
||||
if (def) out.push(def);
|
||||
const dir = path.join(os.homedir(), '.claude-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
const indices = entries
|
||||
.map((e) => Number.parseInt(e, 10))
|
||||
.filter((n) => Number.isFinite(n) && n >= 1)
|
||||
.sort((a, b) => a - b);
|
||||
for (const i of indices) {
|
||||
const acc = readClaudeAccount(i);
|
||||
if (acc) out.push(acc);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listCodexAccounts(): CodexAccountSummary[] {
|
||||
const out: CodexAccountSummary[] = [];
|
||||
const def = readCodexAccount(0);
|
||||
if (def) out.push(def);
|
||||
const dir = path.join(os.homedir(), '.codex-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
const indices = entries
|
||||
.map((e) => Number.parseInt(e, 10))
|
||||
.filter((n) => Number.isFinite(n) && n >= 1)
|
||||
.sort((a, b) => a - b);
|
||||
for (const i of indices) {
|
||||
const acc = readCodexAccount(i);
|
||||
if (acc) out.push(acc);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickEnvValue(content: string, key: string): string | undefined {
|
||||
const re = new RegExp(`^${key}=(.*)$`, 'm');
|
||||
const match = content.match(re);
|
||||
if (!match) return undefined;
|
||||
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readEnvFile(): string {
|
||||
const file = envFilePath();
|
||||
if (!fs.existsSync(file)) return '';
|
||||
return fs.readFileSync(file, 'utf-8');
|
||||
}
|
||||
|
||||
function readEnvOrProcess(key: string): string | undefined {
|
||||
const fromFile = pickEnvValue(readEnvFile(), key);
|
||||
if (fromFile !== undefined) return fromFile;
|
||||
const fromProc = process.env[key];
|
||||
return fromProc;
|
||||
}
|
||||
|
||||
export function getModelConfig(): ModelConfigSnapshot {
|
||||
const out: Partial<ModelConfigSnapshot> = {};
|
||||
for (const role of ROLE_KEYS) {
|
||||
const model = readEnvOrProcess(`${role}_MODEL`) ?? '';
|
||||
const effort = readEnvOrProcess(`${role}_EFFORT`) ?? '';
|
||||
(out as Record<string, ModelRoleConfig>)[role.toLowerCase()] = {
|
||||
model,
|
||||
effort,
|
||||
};
|
||||
}
|
||||
return out as ModelConfigSnapshot;
|
||||
}
|
||||
|
||||
export interface ModelUpdateInput {
|
||||
owner?: Partial<ModelRoleConfig>;
|
||||
reviewer?: Partial<ModelRoleConfig>;
|
||||
arbiter?: Partial<ModelRoleConfig>;
|
||||
}
|
||||
|
||||
function setOrInsertEnvLine(
|
||||
content: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): string {
|
||||
const re = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (re.test(content)) {
|
||||
return content.replace(re, `${key}=${value}`);
|
||||
}
|
||||
const trimmed = content.replace(/\s*$/, '');
|
||||
return `${trimmed}\n${key}=${value}\n`;
|
||||
}
|
||||
|
||||
export function updateModelConfig(
|
||||
input: ModelUpdateInput,
|
||||
): ModelConfigSnapshot {
|
||||
const file = envFilePath();
|
||||
let content = '';
|
||||
if (fs.existsSync(file)) {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
}
|
||||
|
||||
for (const role of ROLE_KEYS) {
|
||||
const update = (
|
||||
input as Record<string, Partial<ModelRoleConfig> | undefined>
|
||||
)[role.toLowerCase()];
|
||||
if (!update) continue;
|
||||
if (update.model !== undefined) {
|
||||
content = setOrInsertEnvLine(content, `${role}_MODEL`, update.model);
|
||||
}
|
||||
if (update.effort !== undefined) {
|
||||
content = setOrInsertEnvLine(content, `${role}_EFFORT`, update.effort);
|
||||
}
|
||||
}
|
||||
|
||||
const tempPath = `${file}.tmp`;
|
||||
fs.writeFileSync(tempPath, content, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, file);
|
||||
|
||||
return getModelConfig();
|
||||
}
|
||||
|
||||
export function removeAccountDirectory(
|
||||
provider: 'claude' | 'codex',
|
||||
index: number,
|
||||
): void {
|
||||
if (!Number.isFinite(index) || index < 1) {
|
||||
throw new Error('cannot remove default account (index 0)');
|
||||
}
|
||||
const baseDir =
|
||||
provider === 'claude'
|
||||
? path.join(os.homedir(), '.claude-accounts', String(index))
|
||||
: path.join(os.homedir(), '.codex-accounts', String(index));
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
throw new Error(`account directory not found: ${baseDir}`);
|
||||
}
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function addClaudeAccountFromToken(token: string): {
|
||||
index: number;
|
||||
accountId: string | null;
|
||||
} {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) throw new Error('empty token');
|
||||
|
||||
const parts = trimmed.split('.');
|
||||
if (parts.length < 2) {
|
||||
throw new Error('invalid OAuth token: expected JWT format');
|
||||
}
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
Buffer.from(parts[1], 'base64').toString('utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error('invalid OAuth token: payload not parseable');
|
||||
}
|
||||
const exp = typeof payload.exp === 'number' ? payload.exp * 1000 : 0;
|
||||
const accountId = typeof payload.sub === 'string' ? payload.sub : null;
|
||||
|
||||
const baseDir = path.join(os.homedir(), '.claude-accounts');
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
let nextIndex = 1;
|
||||
while (
|
||||
fs.existsSync(path.join(baseDir, String(nextIndex), '.credentials.json'))
|
||||
) {
|
||||
nextIndex += 1;
|
||||
}
|
||||
const dir = path.join(baseDir, String(nextIndex));
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const creds = {
|
||||
claudeAiOauth: {
|
||||
accessToken: trimmed,
|
||||
refreshToken: '',
|
||||
expiresAt: exp,
|
||||
scopes: ['user:profile', 'user:inference'],
|
||||
},
|
||||
};
|
||||
const credsPath = path.join(dir, '.credentials.json');
|
||||
const tempPath = `${credsPath}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tempPath, credsPath);
|
||||
|
||||
return { index: nextIndex, accountId };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { UsageRow } from './dashboard-usage-rows.js';
|
||||
import {
|
||||
buildWebUsageRowsForSnapshot,
|
||||
formatStatusHeader,
|
||||
renderUsageTable,
|
||||
summarizeWatcherTasks,
|
||||
@@ -107,3 +108,94 @@ describe('renderUsageTable', () => {
|
||||
expect(lines).toEqual(['_조회 불가_']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebUsageRowsForSnapshot', () => {
|
||||
it('keeps real Claude and Kimi rows ahead of Codex rows for web snapshots', () => {
|
||||
const rows = buildWebUsageRowsForSnapshot({
|
||||
serviceAgentType: 'claude-code',
|
||||
claudeAccounts: [
|
||||
{
|
||||
index: 0,
|
||||
masked: 'claude-1',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
usage: {
|
||||
five_hour: {
|
||||
utilization: 0.2,
|
||||
resets_at: '2026-04-26T12:00:00.000Z',
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 0.4,
|
||||
resets_at: '2026-04-27T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
masked: 'claude-2',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
usage: {
|
||||
five_hour: {
|
||||
utilization: 0.3,
|
||||
resets_at: '2026-04-26T12:00:00.000Z',
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 0.5,
|
||||
resets_at: '2026-04-27T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
kimiUsage: {
|
||||
fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' },
|
||||
weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' },
|
||||
},
|
||||
codexRows: [
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 25,
|
||||
h5reset: '1h',
|
||||
d7pct: 35,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const names = rows.map((row) => row.name);
|
||||
expect(names[0]).toMatch(/^Claude1/);
|
||||
expect(names[1]).toMatch(/^Claude2/);
|
||||
expect(names[2]).toMatch(/^Kimi/);
|
||||
expect(names[3]).toBe('Codex1');
|
||||
});
|
||||
|
||||
it('does not invent Claude or Kimi rows for codex-only services', () => {
|
||||
const rows = buildWebUsageRowsForSnapshot({
|
||||
serviceAgentType: 'codex',
|
||||
claudeAccounts: [
|
||||
{
|
||||
index: 0,
|
||||
masked: 'claude-1',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
usage: null,
|
||||
},
|
||||
],
|
||||
kimiUsage: {
|
||||
fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' },
|
||||
weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' },
|
||||
},
|
||||
codexRows: [
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 25,
|
||||
h5reset: '1h',
|
||||
d7pct: 35,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual(['Codex1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,14 @@ import {
|
||||
STATUS_SHOW_ROOM_DETAILS,
|
||||
STATUS_SHOW_ROOMS,
|
||||
USAGE_DASHBOARD_ENABLED,
|
||||
CODEX_WARMUP_CONFIG,
|
||||
getMoaConfig,
|
||||
} from './config.js';
|
||||
import { fetchKimiUsage, buildKimiUsageRows } from './kimi-usage.js';
|
||||
import {
|
||||
fetchKimiUsage,
|
||||
buildKimiUsageRows,
|
||||
type KimiUsageData,
|
||||
} from './kimi-usage.js';
|
||||
import { getGlobalFailoverInfo } from './service-routing.js';
|
||||
import {
|
||||
fetchAllClaudeUsage,
|
||||
@@ -28,6 +33,7 @@ import {
|
||||
refreshActiveCodexUsage,
|
||||
refreshAllCodexAccountUsage,
|
||||
} from './codex-usage-collector.js';
|
||||
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||
import {
|
||||
composeDashboardContent,
|
||||
formatElapsed,
|
||||
@@ -91,7 +97,7 @@ const RENDERER_USAGE_REFRESH_MS = 30_000;
|
||||
let statusMessageId: string | null = null;
|
||||
let cachedUsageContent = '';
|
||||
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
|
||||
let cachedKimiUsage: import('./kimi-usage.js').KimiUsageData | null = null;
|
||||
let cachedKimiUsage: KimiUsageData | null = null;
|
||||
let usageUpdateInProgress = false;
|
||||
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||
let channelMetaLastRefresh = 0;
|
||||
@@ -100,6 +106,8 @@ let dashboardUpdateLogged = false;
|
||||
let cachedCodexUsageRows: UsageRow[] = [];
|
||||
/** Codex service only: ISO timestamp of last successful usage fetch. */
|
||||
let codexUsageFetchedAt: string | null = null;
|
||||
/** Renderer service only: ISO timestamp of last successful Claude/Kimi usage render. */
|
||||
let rendererUsageFetchedAt: string | null = null;
|
||||
|
||||
export interface WatcherTaskSummary {
|
||||
active: number;
|
||||
@@ -229,9 +237,47 @@ function formatRoomName(
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildWebUsageRowsForSnapshot(args: {
|
||||
serviceAgentType: AgentType;
|
||||
claudeAccounts: ClaudeAccountUsage[];
|
||||
kimiUsage: KimiUsageData | null;
|
||||
codexRows: UsageRow[];
|
||||
}): UsageRow[] {
|
||||
const rows: UsageRow[] = [];
|
||||
|
||||
if (args.serviceAgentType === 'claude-code') {
|
||||
rows.push(...buildClaudeUsageRows(args.claudeAccounts));
|
||||
rows.push(...buildKimiUsageRows(args.kimiUsage));
|
||||
}
|
||||
|
||||
rows.push(...args.codexRows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildUsageSnapshotRows(opts: UnifiedDashboardOptions): {
|
||||
rows: UsageRow[];
|
||||
fetchedAt: string | null;
|
||||
} {
|
||||
const rows = buildWebUsageRowsForSnapshot({
|
||||
serviceAgentType: opts.serviceAgentType,
|
||||
claudeAccounts: cachedClaudeAccounts,
|
||||
kimiUsage: cachedKimiUsage,
|
||||
codexRows: cachedCodexUsageRows,
|
||||
});
|
||||
|
||||
const fetchedAt =
|
||||
[rendererUsageFetchedAt, codexUsageFetchedAt]
|
||||
.filter((value): value is string => !!value)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
return { rows, fetchedAt };
|
||||
}
|
||||
|
||||
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
||||
const groups = opts.roomBindings();
|
||||
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
||||
const usageSnapshot = buildUsageSnapshotRows(opts);
|
||||
|
||||
writeStatusSnapshot({
|
||||
serviceId: opts.serviceId,
|
||||
@@ -265,8 +311,10 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
}>,
|
||||
...(cachedCodexUsageRows.length > 0 && { usageRows: cachedCodexUsageRows }),
|
||||
...(codexUsageFetchedAt && { usageRowsFetchedAt: codexUsageFetchedAt }),
|
||||
...(usageSnapshot.rows.length > 0 && { usageRows: usageSnapshot.rows }),
|
||||
...(usageSnapshot.fetchedAt && {
|
||||
usageRowsFetchedAt: usageSnapshot.fetchedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -645,6 +693,7 @@ async function refreshUsageCache(): Promise<void> {
|
||||
usageUpdateInProgress = true;
|
||||
try {
|
||||
cachedUsageContent = await buildUsageContent();
|
||||
rendererUsageFetchedAt = new Date().toISOString();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to build usage content');
|
||||
} finally {
|
||||
@@ -735,18 +784,49 @@ export async function startUnifiedDashboard(
|
||||
cachedCodexUsageRows = result.rows;
|
||||
if (result.fetchedAt) codexUsageFetchedAt = result.fetchedAt;
|
||||
};
|
||||
void refreshAllCodexAccountUsage().then((r) => {
|
||||
applyCodexRefresh(r);
|
||||
return refreshActiveCodexUsage().then(applyCodexRefresh);
|
||||
});
|
||||
const isWarmupRuntimeBusy = () => {
|
||||
const groups = opts.roomBindings();
|
||||
return opts.queue
|
||||
.getStatuses(Object.keys(groups))
|
||||
.some((status) => status.status === 'processing');
|
||||
};
|
||||
let codexWarmupInFlight = false;
|
||||
const runCodexWarmup = async () => {
|
||||
if (!CODEX_WARMUP_CONFIG.enabled || codexWarmupInFlight) return;
|
||||
codexWarmupInFlight = true;
|
||||
try {
|
||||
const result = await runCodexWarmupCycle(CODEX_WARMUP_CONFIG, {
|
||||
shouldSkip: isWarmupRuntimeBusy,
|
||||
});
|
||||
if (result.status === 'warmed') {
|
||||
applyCodexRefresh(await refreshAllCodexAccountUsage());
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Codex warm-up cycle failed unexpectedly');
|
||||
} finally {
|
||||
codexWarmupInFlight = false;
|
||||
}
|
||||
};
|
||||
void refreshAllCodexAccountUsage()
|
||||
.then((r) => {
|
||||
applyCodexRefresh(r);
|
||||
return refreshActiveCodexUsage().then(applyCodexRefresh);
|
||||
})
|
||||
.then(() => runCodexWarmup());
|
||||
setInterval(
|
||||
() => void refreshActiveCodexUsage().then(applyCodexRefresh),
|
||||
opts.usageUpdateInterval,
|
||||
);
|
||||
setInterval(
|
||||
() => void refreshAllCodexAccountUsage().then(applyCodexRefresh),
|
||||
() =>
|
||||
void refreshAllCodexAccountUsage()
|
||||
.then(applyCodexRefresh)
|
||||
.then(() => runCodexWarmup()),
|
||||
CODEX_FULL_SCAN_INTERVAL,
|
||||
);
|
||||
if (CODEX_WARMUP_CONFIG.enabled) {
|
||||
setInterval(() => void runCodexWarmup(), CODEX_WARMUP_CONFIG.intervalMs);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
|
||||
@@ -106,6 +106,66 @@ describe('verification helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies nested pnpm workspaces under a bun parent without tripping corepack project specs', async () => {
|
||||
const parentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ejclaw-verification-corepack-parent-'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(parentDir, 'package.json'),
|
||||
JSON.stringify({ packageManager: 'bun@1.3.11' }),
|
||||
);
|
||||
|
||||
const repoDir = path.join(parentDir, 'owner');
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'nested-pnpm-workspace',
|
||||
scripts: {
|
||||
typecheck:
|
||||
'node -e "process.stdout.write(process.env.COREPACK_ENABLE_PROJECT_SPEC || \'missing\')"',
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'pnpm-lock.yaml'),
|
||||
[
|
||||
"lockfileVersion: '6.0'",
|
||||
'settings:',
|
||||
' autoInstallPeers: true',
|
||||
' excludeLinksFromLockfile: false',
|
||||
'importers:',
|
||||
' .: {}',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'node_modules', '.bin', 'placeholder'),
|
||||
'',
|
||||
);
|
||||
|
||||
expect(hasInstalledNodeModules(repoDir)).toBe(false);
|
||||
|
||||
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
||||
const result = await runVerificationRequest(
|
||||
{
|
||||
requestId: 'req-corepack-parent-pnpm',
|
||||
profile: 'typecheck',
|
||||
expectedSnapshotId,
|
||||
},
|
||||
{ repoDir },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.command).toBe('corepack pnpm run typecheck');
|
||||
expect(result.stdout).toContain('0');
|
||||
expect(result.snapshotId).toBe(expectedSnapshotId);
|
||||
});
|
||||
|
||||
it('computes a stable snapshot over the readable workspace inputs', () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
||||
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||
|
||||
@@ -5,9 +5,11 @@ import path from 'path';
|
||||
|
||||
import { TIMEZONE } from './config.js';
|
||||
import {
|
||||
buildWorkspaceCommandEnvironment,
|
||||
buildWorkspaceScriptCommand,
|
||||
ensureWorkspaceDependenciesInstalled,
|
||||
hasInstalledNodeModules,
|
||||
type WorkspacePackageManager,
|
||||
} from './workspace-package-manager.js';
|
||||
import { computeVerificationSnapshotId } from '../shared/verification-snapshot.js';
|
||||
|
||||
@@ -44,6 +46,7 @@ export interface VerificationSnapshot {
|
||||
}
|
||||
|
||||
interface VerificationCommandSpec {
|
||||
packageManager: WorkspacePackageManager;
|
||||
file: string;
|
||||
args: string[];
|
||||
commandText: string;
|
||||
@@ -111,6 +114,7 @@ export function buildVerificationCommand(
|
||||
: 'lint';
|
||||
const command = buildWorkspaceScriptCommand(repoDir, scriptName);
|
||||
return {
|
||||
packageManager: command.packageManager,
|
||||
file: command.file,
|
||||
args: command.args,
|
||||
commandText: command.commandText,
|
||||
@@ -334,7 +338,11 @@ export async function runVerificationRequest(
|
||||
command.args,
|
||||
{
|
||||
cwd: repoDir,
|
||||
env: directExecution.env,
|
||||
env: buildWorkspaceCommandEnvironment(
|
||||
repoDir,
|
||||
command.packageManager,
|
||||
directExecution.env,
|
||||
),
|
||||
},
|
||||
);
|
||||
const afterSnapshot = computeVerificationSnapshot(repoDir);
|
||||
|
||||
478
src/web-dashboard-data.test.ts
Normal file
478
src/web-dashboard-data.test.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { StatusSnapshot } from './status-dashboard.js';
|
||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
||||
import type {
|
||||
NewMessage,
|
||||
PairedTask,
|
||||
PairedTurnOutput,
|
||||
ScheduledTask,
|
||||
} from './types.js';
|
||||
import {
|
||||
buildWebDashboardRoomActivity,
|
||||
buildWebDashboardOverview,
|
||||
sanitizeScheduledTask,
|
||||
} from './web-dashboard-data.js';
|
||||
|
||||
function makeTask(overrides: Partial<ScheduledTask>): ScheduledTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
group_folder: 'general',
|
||||
chat_jid: 'dc:general',
|
||||
agent_type: null,
|
||||
status_message_id: null,
|
||||
status_started_at: null,
|
||||
prompt: 'secret long prompt that should not be exposed in full',
|
||||
schedule_type: 'cron',
|
||||
schedule_value: '* * * * *',
|
||||
context_mode: 'group',
|
||||
next_run: '2026-04-26T05:00:00.000Z',
|
||||
last_run: null,
|
||||
last_result: null,
|
||||
status: 'active',
|
||||
created_at: '2026-04-26T04:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
||||
return {
|
||||
id: 'paired-1',
|
||||
chat_jid: 'dc:general',
|
||||
group_folder: 'general',
|
||||
owner_service_id: 'codex-main',
|
||||
reviewer_service_id: 'claude-reviewer',
|
||||
owner_agent_type: 'codex',
|
||||
reviewer_agent_type: 'claude-code',
|
||||
arbiter_agent_type: 'codex',
|
||||
title: 'Dashboard PR',
|
||||
source_ref: null,
|
||||
plan_notes: null,
|
||||
review_requested_at: null,
|
||||
round_trip_count: 0,
|
||||
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: 'review_ready',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-04-26T04:00:00.000Z',
|
||||
updated_at: '2026-04-26T04:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('web dashboard data', () => {
|
||||
it('builds overview counts from status snapshots and scheduled tasks', () => {
|
||||
const snapshots: StatusSnapshot[] = [
|
||||
{
|
||||
serviceId: 'codex-main',
|
||||
agentType: 'codex',
|
||||
assistantName: 'Codex',
|
||||
updatedAt: '2026-04-26T04:59:00.000Z',
|
||||
entries: [
|
||||
{
|
||||
jid: 'dc:1',
|
||||
name: '#general',
|
||||
folder: 'general',
|
||||
agentType: 'codex',
|
||||
status: 'processing',
|
||||
elapsedMs: 1200,
|
||||
pendingMessages: true,
|
||||
pendingTasks: 2,
|
||||
},
|
||||
{
|
||||
jid: 'dc:2',
|
||||
name: '#brain',
|
||||
folder: 'brain',
|
||||
agentType: 'claude-code',
|
||||
status: 'inactive',
|
||||
elapsedMs: null,
|
||||
pendingMessages: false,
|
||||
pendingTasks: 0,
|
||||
},
|
||||
],
|
||||
usageRows: [
|
||||
{
|
||||
name: 'codex-a',
|
||||
h5pct: 10,
|
||||
h5reset: '1h',
|
||||
d7pct: 20,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const overview = buildWebDashboardOverview({
|
||||
now: '2026-04-26T05:00:00.000Z',
|
||||
snapshots,
|
||||
tasks: [
|
||||
makeTask({
|
||||
id: 'watch-1',
|
||||
prompt: '[BACKGROUND CI WATCH] owner/repo#1',
|
||||
status: 'active',
|
||||
}),
|
||||
makeTask({ id: 'cron-1', prompt: 'regular cleanup', status: 'paused' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(overview.rooms.total).toBe(2);
|
||||
expect(overview.rooms.active).toBe(1);
|
||||
expect(overview.rooms.waiting).toBe(0);
|
||||
expect(overview.rooms.inactive).toBe(1);
|
||||
expect(overview.tasks.total).toBe(2);
|
||||
expect(overview.tasks.active).toBe(1);
|
||||
expect(overview.tasks.paused).toBe(1);
|
||||
expect(overview.tasks.watchers.active).toBe(1);
|
||||
expect(overview.usage.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deduplicates full usage rows from renderer and codex snapshots', () => {
|
||||
const snapshots: StatusSnapshot[] = [
|
||||
{
|
||||
serviceId: 'codex-main',
|
||||
agentType: 'codex',
|
||||
assistantName: 'Codex',
|
||||
updatedAt: '2026-04-26T04:59:00.000Z',
|
||||
entries: [],
|
||||
usageRowsFetchedAt: '2026-04-26T04:59:00.000Z',
|
||||
usageRows: [
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 20,
|
||||
h5reset: '1h',
|
||||
d7pct: 30,
|
||||
d7reset: '2d',
|
||||
},
|
||||
{
|
||||
name: 'Codex2',
|
||||
h5pct: 15,
|
||||
h5reset: '1h',
|
||||
d7pct: 18,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
serviceId: 'claude-main',
|
||||
agentType: 'claude-code',
|
||||
assistantName: 'Claude',
|
||||
updatedAt: '2026-04-26T05:00:00.000Z',
|
||||
entries: [],
|
||||
usageRowsFetchedAt: '2026-04-26T05:00:00.000Z',
|
||||
usageRows: [
|
||||
{
|
||||
name: 'Claude1 Max',
|
||||
h5pct: 66,
|
||||
h5reset: '2h',
|
||||
d7pct: 40,
|
||||
d7reset: '4d',
|
||||
},
|
||||
{
|
||||
name: 'Kimi',
|
||||
h5pct: 10,
|
||||
h5reset: '3h',
|
||||
d7pct: 12,
|
||||
d7reset: '5d',
|
||||
},
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 25,
|
||||
h5reset: '55m',
|
||||
d7pct: 35,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const overview = buildWebDashboardOverview({
|
||||
now: '2026-04-26T05:01:00.000Z',
|
||||
snapshots,
|
||||
tasks: [],
|
||||
});
|
||||
|
||||
expect(overview.usage.rows.map((row) => row.name)).toEqual([
|
||||
'Claude1 Max',
|
||||
'Kimi',
|
||||
'Codex1',
|
||||
'Codex2',
|
||||
]);
|
||||
expect(
|
||||
overview.usage.rows.filter((row) => row.name === 'Codex1'),
|
||||
).toHaveLength(1);
|
||||
expect(overview.usage.fetchedAt).toBe('2026-04-26T05:00:00.000Z');
|
||||
});
|
||||
|
||||
it('does not expose full scheduled task prompts through API payloads', () => {
|
||||
const sanitized = sanitizeScheduledTask(
|
||||
makeTask({
|
||||
prompt: 'x'.repeat(220),
|
||||
last_result: 'ok',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sanitized).not.toHaveProperty('prompt');
|
||||
expect(sanitized.promptPreview.length).toBeLessThanOrEqual(123);
|
||||
expect(sanitized.promptLength).toBe(220);
|
||||
});
|
||||
|
||||
it('redacts common secret values from scheduled task prompt previews', () => {
|
||||
const sanitized = sanitizeScheduledTask(
|
||||
makeTask({
|
||||
prompt:
|
||||
'deploy with OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456 and BOT_TOKEN=plain-secret-value',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sanitized.promptPreview).toContain('OPENAI_API_KEY=<redacted>');
|
||||
expect(sanitized.promptPreview).toContain('BOT_TOKEN=<redacted>');
|
||||
expect(sanitized.promptPreview).not.toContain(
|
||||
'sk-abcdefghijklmnopqrstuvwxyz123456',
|
||||
);
|
||||
expect(sanitized.promptPreview).not.toContain('plain-secret-value');
|
||||
});
|
||||
|
||||
it('builds redacted room activity from messages and paired turn output', () => {
|
||||
const task = makePairedTask({
|
||||
id: 'paired-room-1',
|
||||
chat_jid: 'dc:ops',
|
||||
status: 'in_review',
|
||||
round_trip_count: 3,
|
||||
updated_at: '2026-04-26T05:30:00.000Z',
|
||||
});
|
||||
const turn: PairedTurnRecord = {
|
||||
turn_id: 'turn-1',
|
||||
task_id: task.id,
|
||||
task_updated_at: task.updated_at,
|
||||
role: 'reviewer',
|
||||
intent_kind: 'reviewer-turn',
|
||||
state: 'queued',
|
||||
executor_service_id: null,
|
||||
executor_agent_type: null,
|
||||
attempt_no: 0,
|
||||
created_at: '2026-04-26T05:19:00.000Z',
|
||||
updated_at: '2026-04-26T05:31:00.000Z',
|
||||
completed_at: null,
|
||||
last_error: null,
|
||||
};
|
||||
const attempt: PairedTurnAttemptRecord = {
|
||||
attempt_id: 'turn-1:attempt:2',
|
||||
parent_attempt_id: null,
|
||||
parent_handoff_id: null,
|
||||
continuation_handoff_id: null,
|
||||
turn_id: 'turn-1',
|
||||
task_id: task.id,
|
||||
task_updated_at: task.updated_at,
|
||||
role: 'reviewer',
|
||||
intent_kind: 'reviewer-turn',
|
||||
state: 'running',
|
||||
executor_service_id: 'claude-reviewer',
|
||||
executor_agent_type: 'claude-code',
|
||||
active_run_id: 'run-reviewer-1',
|
||||
attempt_no: 2,
|
||||
created_at: '2026-04-26T05:20:00.000Z',
|
||||
updated_at: '2026-04-26T05:31:00.000Z',
|
||||
completed_at: null,
|
||||
last_error: 'OPENAI_API_KEY=plain-secret-value',
|
||||
};
|
||||
const outputs: PairedTurnOutput[] = [
|
||||
{
|
||||
id: 1,
|
||||
task_id: task.id,
|
||||
turn_number: 1,
|
||||
role: 'owner',
|
||||
output_text: 'owner output',
|
||||
verdict: 'step_done',
|
||||
created_at: '2026-04-26T05:25:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
task_id: task.id,
|
||||
turn_number: 2,
|
||||
role: 'reviewer',
|
||||
output_text: 'reviewer output with BOT_TOKEN=plain-secret-value',
|
||||
verdict: null,
|
||||
created_at: '2026-04-26T05:30:00.000Z',
|
||||
},
|
||||
];
|
||||
const messages: NewMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: 'dc:ops',
|
||||
sender: 'user-1',
|
||||
sender_name: '눈쟁이',
|
||||
content: 'latest message',
|
||||
timestamp: '2026-04-26T05:29:00.000Z',
|
||||
is_from_me: false,
|
||||
is_bot_message: false,
|
||||
message_source_kind: 'human',
|
||||
},
|
||||
];
|
||||
|
||||
const activity = buildWebDashboardRoomActivity({
|
||||
serviceId: 'codex-main',
|
||||
entry: {
|
||||
jid: 'dc:ops',
|
||||
name: '#ops',
|
||||
folder: 'ops',
|
||||
agentType: 'codex',
|
||||
status: 'processing',
|
||||
elapsedMs: 15_000,
|
||||
pendingMessages: true,
|
||||
pendingTasks: 1,
|
||||
},
|
||||
pairedTask: task,
|
||||
turns: [turn],
|
||||
attempts: [attempt],
|
||||
outputs,
|
||||
messages,
|
||||
outputLimit: 1,
|
||||
});
|
||||
|
||||
expect(activity.pairedTask).toMatchObject({
|
||||
id: 'paired-room-1',
|
||||
roundTripCount: 3,
|
||||
currentTurn: {
|
||||
role: 'reviewer',
|
||||
state: 'running',
|
||||
attemptNo: 2,
|
||||
lastError: 'OPENAI_API_KEY=<redacted>',
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
turnNumber: 2,
|
||||
role: 'reviewer',
|
||||
outputText: 'reviewer output with BOT_TOKEN=<redacted>',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(activity.messages).toMatchObject([
|
||||
{ senderName: '눈쟁이', content: 'latest message' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => {
|
||||
const snapshots: StatusSnapshot[] = [
|
||||
{
|
||||
serviceId: 'codex-main',
|
||||
agentType: 'codex',
|
||||
assistantName: 'Codex',
|
||||
updatedAt: '2026-04-26T05:00:00.000Z',
|
||||
entries: [
|
||||
{
|
||||
jid: 'dc:ops',
|
||||
name: '#ops',
|
||||
folder: 'ops',
|
||||
agentType: 'codex',
|
||||
status: 'waiting',
|
||||
elapsedMs: 1000,
|
||||
pendingMessages: true,
|
||||
pendingTasks: 0,
|
||||
},
|
||||
{
|
||||
jid: 'dc:idle',
|
||||
name: '#idle',
|
||||
folder: 'idle',
|
||||
agentType: 'codex',
|
||||
status: 'inactive',
|
||||
elapsedMs: null,
|
||||
pendingMessages: false,
|
||||
pendingTasks: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const overview = buildWebDashboardOverview({
|
||||
now: '2026-04-26T05:10:00.000Z',
|
||||
snapshots,
|
||||
pairedTasks: [
|
||||
makePairedTask({
|
||||
id: 'review-1',
|
||||
status: 'review_ready',
|
||||
review_requested_at: '2026-04-26T05:03:00.000Z',
|
||||
}),
|
||||
makePairedTask({
|
||||
id: 'merge-1',
|
||||
status: 'merge_ready',
|
||||
title: 'Ready to merge',
|
||||
updated_at: '2026-04-26T05:04:00.000Z',
|
||||
}),
|
||||
makePairedTask({
|
||||
id: 'done-1',
|
||||
status: 'completed',
|
||||
}),
|
||||
],
|
||||
tasks: [
|
||||
makeTask({
|
||||
id: 'ci-1',
|
||||
prompt:
|
||||
'[BACKGROUND CI WATCH]\nWatch target:\nPR #21\n\nCheck instructions:\nwatch',
|
||||
last_run: '2026-04-26T05:05:00.000Z',
|
||||
last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
|
||||
status: 'paused',
|
||||
}),
|
||||
makeTask({
|
||||
id: 'ci-2',
|
||||
prompt:
|
||||
'[BACKGROUND CI WATCH]\nWatch target:\nPR #22\n\nCheck instructions:\nwatch',
|
||||
last_run: '2026-04-26T05:07:00.000Z',
|
||||
last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
|
||||
status: 'active',
|
||||
}),
|
||||
makeTask({
|
||||
id: 'cron-1',
|
||||
prompt: 'regular cron',
|
||||
last_run: '2026-04-26T05:06:00.000Z',
|
||||
last_result: 'Error: non-watch task failed',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(overview.inbox.map((item) => item.kind)).toEqual([
|
||||
'ci-failure',
|
||||
'approval',
|
||||
'reviewer-request',
|
||||
'pending-room',
|
||||
]);
|
||||
expect(overview.inbox).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'room:codex-main:dc:ops',
|
||||
kind: 'pending-room',
|
||||
severity: 'info',
|
||||
roomJid: 'dc:ops',
|
||||
serviceId: 'codex-main',
|
||||
occurredAt: '2026-04-26T05:00:00.000Z',
|
||||
createdAt: '2026-04-26T05:10:00.000Z',
|
||||
}),
|
||||
);
|
||||
expect(overview.inbox).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'paired:review-1:review_ready',
|
||||
kind: 'reviewer-request',
|
||||
severity: 'warn',
|
||||
serviceId: 'claude-reviewer',
|
||||
taskStatus: 'review_ready',
|
||||
occurredAt: '2026-04-26T05:03:00.000Z',
|
||||
}),
|
||||
);
|
||||
const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure');
|
||||
expect(ciFailure).toMatchObject({
|
||||
id: 'ci:ci-2',
|
||||
severity: 'error',
|
||||
occurrences: 2,
|
||||
source: 'scheduled-task',
|
||||
taskId: 'ci-2',
|
||||
lastOccurredAt: '2026-04-26T05:07:00.000Z',
|
||||
});
|
||||
expect(ciFailure?.summary).toContain('BOT_TOKEN=<redacted>');
|
||||
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
||||
expect(overview.inbox.some((item) => item.taskId === 'cron-1')).toBe(false);
|
||||
expect(overview.inbox.some((item) => item.taskId === 'done-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
668
src/web-dashboard-data.ts
Normal file
668
src/web-dashboard-data.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
import { isWatchCiTask } from './task-watch-status.js';
|
||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
||||
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
||||
import type {
|
||||
NewMessage,
|
||||
PairedTask,
|
||||
PairedTurnOutput,
|
||||
ScheduledTask,
|
||||
} from './types.js';
|
||||
|
||||
export interface SanitizedScheduledTask {
|
||||
id: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
agentType: ScheduledTask['agent_type'];
|
||||
ciProvider: ScheduledTask['ci_provider'];
|
||||
ciMetadata: ScheduledTask['ci_metadata'];
|
||||
scheduleType: ScheduledTask['schedule_type'];
|
||||
scheduleValue: string;
|
||||
contextMode: ScheduledTask['context_mode'];
|
||||
nextRun: string | null;
|
||||
lastRun: string | null;
|
||||
lastResult: string | null;
|
||||
status: ScheduledTask['status'];
|
||||
suspendedUntil: string | null;
|
||||
createdAt: string;
|
||||
promptPreview: string;
|
||||
promptLength: number;
|
||||
isWatcher: boolean;
|
||||
}
|
||||
|
||||
export interface WebDashboardOverview {
|
||||
generatedAt: string;
|
||||
services: Array<{
|
||||
serviceId: string;
|
||||
assistantName: string;
|
||||
agentType: StatusSnapshot['agentType'];
|
||||
updatedAt: string;
|
||||
totalRooms: number;
|
||||
activeRooms: number;
|
||||
}>;
|
||||
rooms: {
|
||||
total: number;
|
||||
active: number;
|
||||
waiting: number;
|
||||
inactive: number;
|
||||
};
|
||||
tasks: {
|
||||
total: number;
|
||||
active: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
watchers: {
|
||||
active: number;
|
||||
paused: number;
|
||||
completed: number;
|
||||
};
|
||||
};
|
||||
usage: {
|
||||
rows: UsageRowSnapshot[];
|
||||
fetchedAt: string | null;
|
||||
};
|
||||
inbox: InboxItem[];
|
||||
}
|
||||
|
||||
export interface WebDashboardRoomMessage {
|
||||
id: string;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
isFromMe: boolean;
|
||||
isBotMessage: boolean;
|
||||
sourceKind: NonNullable<NewMessage['message_source_kind']>;
|
||||
}
|
||||
|
||||
export interface WebDashboardRoomTurn {
|
||||
turnId: string;
|
||||
role: PairedTurnRecord['role'];
|
||||
intentKind: PairedTurnRecord['intent_kind'];
|
||||
state: PairedTurnRecord['state'];
|
||||
attemptNo: number;
|
||||
executorServiceId: string | null;
|
||||
executorAgentType: PairedTurnRecord['executor_agent_type'];
|
||||
activeRunId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
lastError: string | null;
|
||||
progressText: string | null;
|
||||
progressUpdatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WebDashboardRoomTurnOutput {
|
||||
id: number;
|
||||
turnNumber: number;
|
||||
role: PairedTurnOutput['role'];
|
||||
verdict: PairedTurnOutput['verdict'] | null;
|
||||
createdAt: string;
|
||||
outputText: string;
|
||||
}
|
||||
|
||||
export interface WebDashboardRoomActivity {
|
||||
serviceId: string;
|
||||
jid: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
agentType: StatusSnapshot['agentType'];
|
||||
status: StatusSnapshot['entries'][number]['status'];
|
||||
elapsedMs: number | null;
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
messages: WebDashboardRoomMessage[];
|
||||
pairedTask: {
|
||||
id: string;
|
||||
title: string | null;
|
||||
status: PairedTask['status'];
|
||||
roundTripCount: number;
|
||||
updatedAt: string;
|
||||
currentTurn: WebDashboardRoomTurn | null;
|
||||
outputs: WebDashboardRoomTurnOutput[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type InboxItemKind =
|
||||
| 'pending-room'
|
||||
| 'reviewer-request'
|
||||
| 'approval'
|
||||
| 'arbiter-request'
|
||||
| 'ci-failure'
|
||||
| 'mention';
|
||||
|
||||
export type InboxItemSeverity = 'info' | 'warn' | 'error';
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
groupKey: string;
|
||||
kind: InboxItemKind;
|
||||
severity: InboxItemSeverity;
|
||||
title: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
lastOccurredAt: string;
|
||||
createdAt: string;
|
||||
occurrences: number;
|
||||
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
||||
roomJid?: string;
|
||||
roomName?: string;
|
||||
groupFolder?: string;
|
||||
serviceId?: string;
|
||||
taskId?: string;
|
||||
taskStatus?: string;
|
||||
}
|
||||
|
||||
const SECRET_ASSIGNMENT_RE =
|
||||
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
|
||||
const SECRET_VALUE_RE =
|
||||
/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g;
|
||||
const ROOM_MESSAGE_PREVIEW_MAX_LENGTH = 900;
|
||||
const ROOM_OUTPUT_PREVIEW_MAX_LENGTH = 1800;
|
||||
|
||||
function redactSensitiveText(value: string): string {
|
||||
return value
|
||||
.replace(SECRET_ASSIGNMENT_RE, '$1=<redacted>')
|
||||
.replace(SECRET_VALUE_RE, '<redacted-token>');
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength = 120): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
function buildPromptPreview(prompt: string): string {
|
||||
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
function buildInboxPreview(value: string): string {
|
||||
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
function buildRoomPreview(value: string, maxLength: number): string {
|
||||
return truncateText(redactSensitiveText(value).trim(), maxLength);
|
||||
}
|
||||
|
||||
function stableHash(value: string): string {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
||||
const rows: UsageRowSnapshot[] = [];
|
||||
const seen = new Set<string>();
|
||||
const sortedSnapshots = [...snapshots].sort((a, b) =>
|
||||
(b.usageRowsFetchedAt ?? b.updatedAt).localeCompare(
|
||||
a.usageRowsFetchedAt ?? a.updatedAt,
|
||||
),
|
||||
);
|
||||
|
||||
for (const snapshot of sortedSnapshots) {
|
||||
for (const row of snapshot.usageRows ?? []) {
|
||||
if (seen.has(row.name)) continue;
|
||||
seen.add(row.name);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function failedTaskResult(task: ScheduledTask): string | null {
|
||||
if (!task.last_result) return null;
|
||||
const normalized = task.last_result.toLowerCase();
|
||||
if (
|
||||
normalized.includes('fail') ||
|
||||
normalized.includes('error') ||
|
||||
normalized.includes('timeout') ||
|
||||
normalized.includes('cancel') ||
|
||||
normalized.includes('reject')
|
||||
) {
|
||||
return buildInboxPreview(task.last_result);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pairedTaskInboxKind(
|
||||
status: PairedTask['status'],
|
||||
): InboxItemKind | null {
|
||||
if (status === 'merge_ready') return 'approval';
|
||||
if (status === 'review_ready' || status === 'in_review') {
|
||||
return 'reviewer-request';
|
||||
}
|
||||
if (status === 'arbiter_requested' || status === 'in_arbitration') {
|
||||
return 'arbiter-request';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectInboxItems(args: {
|
||||
snapshots: StatusSnapshot[];
|
||||
tasks: ScheduledTask[];
|
||||
pairedTasks: PairedTask[];
|
||||
createdAt: string;
|
||||
}): InboxItem[] {
|
||||
const items: InboxItem[] = [];
|
||||
|
||||
for (const snapshot of args.snapshots) {
|
||||
for (const entry of snapshot.entries) {
|
||||
if (!entry.pendingMessages && entry.pendingTasks === 0) continue;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (entry.pendingMessages) parts.push('pending messages');
|
||||
if (entry.pendingTasks > 0) parts.push(`${entry.pendingTasks} tasks`);
|
||||
|
||||
items.push({
|
||||
id: `room:${snapshot.serviceId}:${entry.jid}`,
|
||||
groupKey: `room:${snapshot.serviceId}:${entry.jid}`,
|
||||
kind: 'pending-room',
|
||||
severity: entry.pendingTasks > 0 ? 'warn' : 'info',
|
||||
title: entry.name || entry.folder || entry.jid,
|
||||
summary: parts.join(' · '),
|
||||
occurredAt: snapshot.updatedAt,
|
||||
lastOccurredAt: snapshot.updatedAt,
|
||||
createdAt: args.createdAt,
|
||||
occurrences: 1,
|
||||
source: 'status-snapshot',
|
||||
roomJid: entry.jid,
|
||||
roomName: entry.name,
|
||||
groupFolder: entry.folder,
|
||||
serviceId: snapshot.serviceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of args.pairedTasks) {
|
||||
const kind = pairedTaskInboxKind(task.status);
|
||||
if (!kind) continue;
|
||||
|
||||
items.push({
|
||||
id: `paired:${task.id}:${task.status}`,
|
||||
groupKey: `paired:${task.id}:${task.status}`,
|
||||
kind,
|
||||
severity: kind === 'arbiter-request' ? 'error' : 'warn',
|
||||
title: task.title || task.group_folder,
|
||||
summary: task.status,
|
||||
occurredAt:
|
||||
kind === 'arbiter-request'
|
||||
? (task.arbiter_requested_at ?? task.updated_at)
|
||||
: kind === 'reviewer-request'
|
||||
? (task.review_requested_at ?? task.updated_at)
|
||||
: task.updated_at,
|
||||
lastOccurredAt:
|
||||
kind === 'arbiter-request'
|
||||
? (task.arbiter_requested_at ?? task.updated_at)
|
||||
: kind === 'reviewer-request'
|
||||
? (task.review_requested_at ?? task.updated_at)
|
||||
: task.updated_at,
|
||||
createdAt: args.createdAt,
|
||||
occurrences: 1,
|
||||
source: 'paired-task',
|
||||
roomJid: task.chat_jid,
|
||||
groupFolder: task.group_folder,
|
||||
serviceId:
|
||||
kind === 'reviewer-request'
|
||||
? task.reviewer_service_id
|
||||
: task.owner_service_id,
|
||||
taskId: task.id,
|
||||
taskStatus: task.status,
|
||||
});
|
||||
}
|
||||
|
||||
for (const task of args.tasks) {
|
||||
if (!isWatchCiTask(task)) continue;
|
||||
const result = failedTaskResult(task);
|
||||
if (!result) continue;
|
||||
|
||||
items.push({
|
||||
id: `ci:${task.id}`,
|
||||
groupKey: `ci:${stableHash(result.toLowerCase())}`,
|
||||
kind: 'ci-failure',
|
||||
severity: task.status === 'paused' ? 'error' : 'warn',
|
||||
title: 'CI watcher failed',
|
||||
summary: result,
|
||||
occurredAt: task.last_run ?? task.created_at,
|
||||
lastOccurredAt: task.last_run ?? task.created_at,
|
||||
createdAt: args.createdAt,
|
||||
occurrences: 1,
|
||||
source: 'scheduled-task',
|
||||
roomJid: task.chat_jid,
|
||||
groupFolder: task.group_folder,
|
||||
serviceId: task.agent_type ?? undefined,
|
||||
taskId: task.id,
|
||||
taskStatus: task.status,
|
||||
});
|
||||
}
|
||||
|
||||
const severityRank: Record<InboxItemSeverity, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
};
|
||||
|
||||
const groupedItems = new Map<string, InboxItem>();
|
||||
for (const item of items) {
|
||||
const existing = groupedItems.get(item.groupKey);
|
||||
if (!existing) {
|
||||
groupedItems.set(item.groupKey, item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const occurrences = existing.occurrences + item.occurrences;
|
||||
const severity =
|
||||
severityRank[item.severity] < severityRank[existing.severity]
|
||||
? item.severity
|
||||
: existing.severity;
|
||||
const latest =
|
||||
item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0
|
||||
? item
|
||||
: existing;
|
||||
|
||||
groupedItems.set(item.groupKey, {
|
||||
...latest,
|
||||
severity,
|
||||
occurrences,
|
||||
lastOccurredAt:
|
||||
item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0
|
||||
? item.lastOccurredAt
|
||||
: existing.lastOccurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
return [...groupedItems.values()]
|
||||
.sort((a, b) => {
|
||||
const severityDelta = severityRank[a.severity] - severityRank[b.severity];
|
||||
if (severityDelta !== 0) return severityDelta;
|
||||
return b.lastOccurredAt.localeCompare(a.lastOccurredAt);
|
||||
})
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
export function sanitizeScheduledTask(
|
||||
task: ScheduledTask,
|
||||
): SanitizedScheduledTask {
|
||||
return {
|
||||
id: task.id,
|
||||
groupFolder: task.group_folder,
|
||||
chatJid: task.chat_jid,
|
||||
agentType: task.agent_type,
|
||||
ciProvider: task.ci_provider ?? null,
|
||||
ciMetadata: task.ci_metadata ?? null,
|
||||
scheduleType: task.schedule_type,
|
||||
scheduleValue: task.schedule_value,
|
||||
contextMode: task.context_mode,
|
||||
nextRun: task.next_run,
|
||||
lastRun: task.last_run,
|
||||
lastResult: task.last_result,
|
||||
status: task.status,
|
||||
suspendedUntil: task.suspended_until ?? null,
|
||||
createdAt: task.created_at,
|
||||
promptPreview: buildPromptPreview(task.prompt),
|
||||
promptLength: task.prompt.length,
|
||||
isWatcher: isWatchCiTask(task),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeRoomMessage(message: NewMessage): WebDashboardRoomMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
senderName: message.sender_name || message.sender,
|
||||
content: buildRoomPreview(
|
||||
message.content ?? '',
|
||||
ROOM_MESSAGE_PREVIEW_MAX_LENGTH,
|
||||
),
|
||||
timestamp: message.timestamp,
|
||||
isFromMe: !!message.is_from_me,
|
||||
isBotMessage: !!message.is_bot_message,
|
||||
sourceKind: message.message_source_kind ?? 'human',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSOT for live progress: scan the `messages` table for the most recent
|
||||
* TASK_STATUS_MESSAGE_PREFIX-prefixed message that belongs to the current
|
||||
* turn (sender role match + timestamp >= turn.created_at). The Discord
|
||||
* bridge ingests every edit of the bot's live message back into messages,
|
||||
* so this single source replaces the legacy paired_turns.progress_text
|
||||
* column for derivation.
|
||||
*/
|
||||
const TASK_STATUS_PREFIX = '';
|
||||
|
||||
function turnRoleFromSenderName(name: string | null | undefined): string {
|
||||
const v = (name ?? '').toLowerCase();
|
||||
if (v.includes('오너') || v.includes('owner')) return 'owner';
|
||||
if (v.includes('리뷰어') || v.includes('reviewer')) return 'reviewer';
|
||||
if (v.includes('중재자') || v.includes('arbiter')) return 'arbiter';
|
||||
return 'human';
|
||||
}
|
||||
|
||||
function deriveLiveProgress(
|
||||
turnRole: string,
|
||||
turnCreatedAt: string,
|
||||
messages: NewMessage[],
|
||||
): { progressText: string | null; progressUpdatedAt: string | null } {
|
||||
const startMs = new Date(turnCreatedAt).getTime();
|
||||
const targetRole = turnRoleFromSenderName(turnRole);
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
const content = m.content ?? '';
|
||||
if (!content.startsWith(TASK_STATUS_PREFIX)) continue;
|
||||
const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
|
||||
if (Number.isFinite(startMs) && ts < startMs) continue;
|
||||
const role = turnRoleFromSenderName(m.sender_name);
|
||||
if (role !== targetRole) continue;
|
||||
const body = content.slice(TASK_STATUS_PREFIX.length);
|
||||
const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
|
||||
if (!stripped) continue;
|
||||
return { progressText: stripped, progressUpdatedAt: m.timestamp };
|
||||
}
|
||||
return { progressText: null, progressUpdatedAt: null };
|
||||
}
|
||||
|
||||
function sanitizeRoomTurn(
|
||||
turn: PairedTurnRecord,
|
||||
attempt: PairedTurnAttemptRecord | null,
|
||||
messages: NewMessage[] = [],
|
||||
): WebDashboardRoomTurn {
|
||||
const role = attempt?.role ?? turn.role;
|
||||
const createdAt = attempt?.created_at ?? turn.created_at;
|
||||
const completedAt = attempt?.completed_at ?? turn.completed_at;
|
||||
|
||||
// Read paired_turns.progress_text first (written by recordTurnProgress
|
||||
// when the controller is in the loop). If that's empty AND the turn is
|
||||
// active, fall back to scanning the messages table for the most recent
|
||||
// TASK_STATUS-prefixed message — same content the Discord bridge ingests.
|
||||
let progressText = turn.progress_text ?? null;
|
||||
let progressUpdatedAt = turn.progress_updated_at ?? null;
|
||||
if ((!progressText || !progressText.trim()) && completedAt === null) {
|
||||
const live = deriveLiveProgress(role, createdAt, messages);
|
||||
if (live.progressText) {
|
||||
progressText = live.progressText;
|
||||
progressUpdatedAt = live.progressUpdatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
turnId: turn.turn_id,
|
||||
role,
|
||||
intentKind: attempt?.intent_kind ?? turn.intent_kind,
|
||||
state: attempt?.state ?? turn.state,
|
||||
attemptNo: attempt?.attempt_no ?? turn.attempt_no,
|
||||
executorServiceId: attempt?.executor_service_id ?? turn.executor_service_id,
|
||||
executorAgentType: attempt?.executor_agent_type ?? turn.executor_agent_type,
|
||||
activeRunId: attempt?.active_run_id ?? null,
|
||||
createdAt,
|
||||
updatedAt: attempt?.updated_at ?? turn.updated_at,
|
||||
completedAt,
|
||||
lastError:
|
||||
(attempt?.last_error ?? turn.last_error)
|
||||
? buildRoomPreview(
|
||||
attempt?.last_error ?? turn.last_error ?? '',
|
||||
ROOM_MESSAGE_PREVIEW_MAX_LENGTH,
|
||||
)
|
||||
: null,
|
||||
progressText,
|
||||
progressUpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeRoomTurnOutput(
|
||||
output: PairedTurnOutput,
|
||||
): WebDashboardRoomTurnOutput {
|
||||
return {
|
||||
id: output.id,
|
||||
turnNumber: output.turn_number,
|
||||
role: output.role,
|
||||
verdict: output.verdict ?? null,
|
||||
createdAt: output.created_at,
|
||||
outputText: buildRoomPreview(
|
||||
output.output_text,
|
||||
ROOM_OUTPUT_PREVIEW_MAX_LENGTH,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebDashboardRoomActivity(args: {
|
||||
serviceId: string;
|
||||
entry: StatusSnapshot['entries'][number];
|
||||
pairedTask: PairedTask | null;
|
||||
turns: PairedTurnRecord[];
|
||||
attempts: PairedTurnAttemptRecord[];
|
||||
outputs: PairedTurnOutput[];
|
||||
messages: NewMessage[];
|
||||
outputLimit?: number;
|
||||
}): WebDashboardRoomActivity {
|
||||
const latestAttemptByTurnId = new Map<string, PairedTurnAttemptRecord>();
|
||||
for (const attempt of args.attempts) {
|
||||
const previous = latestAttemptByTurnId.get(attempt.turn_id);
|
||||
if (!previous || attempt.attempt_no > previous.attempt_no) {
|
||||
latestAttemptByTurnId.set(attempt.turn_id, attempt);
|
||||
}
|
||||
}
|
||||
const currentTurn =
|
||||
[...args.turns].sort((a, b) =>
|
||||
b.updated_at.localeCompare(a.updated_at),
|
||||
)[0] ?? null;
|
||||
const currentAttempt = currentTurn
|
||||
? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null)
|
||||
: null;
|
||||
const outputLimit = args.outputLimit ?? 4;
|
||||
|
||||
return {
|
||||
serviceId: args.serviceId,
|
||||
jid: args.entry.jid,
|
||||
name: args.entry.name,
|
||||
folder: args.entry.folder,
|
||||
agentType: args.entry.agentType,
|
||||
status: args.entry.status,
|
||||
elapsedMs: args.entry.elapsedMs,
|
||||
pendingMessages: args.entry.pendingMessages,
|
||||
pendingTasks: args.entry.pendingTasks,
|
||||
messages: args.messages.map(sanitizeRoomMessage),
|
||||
pairedTask: args.pairedTask
|
||||
? {
|
||||
id: args.pairedTask.id,
|
||||
title: args.pairedTask.title,
|
||||
status: args.pairedTask.status,
|
||||
roundTripCount: args.pairedTask.round_trip_count,
|
||||
updatedAt: args.pairedTask.updated_at,
|
||||
currentTurn: currentTurn
|
||||
? sanitizeRoomTurn(currentTurn, currentAttempt, args.messages)
|
||||
: null,
|
||||
outputs: args.outputs.slice(-outputLimit).map(sanitizeRoomTurnOutput),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebDashboardOverview(args: {
|
||||
now?: string;
|
||||
snapshots: StatusSnapshot[];
|
||||
tasks: ScheduledTask[];
|
||||
pairedTasks?: PairedTask[];
|
||||
}): WebDashboardOverview {
|
||||
const generatedAt = args.now ?? new Date().toISOString();
|
||||
const rooms = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
waiting: 0,
|
||||
inactive: 0,
|
||||
};
|
||||
|
||||
const services = args.snapshots.map((snapshot) => {
|
||||
let activeRooms = 0;
|
||||
for (const entry of snapshot.entries) {
|
||||
rooms.total += 1;
|
||||
if (entry.status === 'processing') {
|
||||
rooms.active += 1;
|
||||
activeRooms += 1;
|
||||
} else if (entry.status === 'waiting') {
|
||||
rooms.waiting += 1;
|
||||
activeRooms += 1;
|
||||
} else {
|
||||
rooms.inactive += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
serviceId: snapshot.serviceId,
|
||||
assistantName: snapshot.assistantName,
|
||||
agentType: snapshot.agentType,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
totalRooms: snapshot.entries.length,
|
||||
activeRooms,
|
||||
};
|
||||
});
|
||||
|
||||
const tasks = {
|
||||
total: args.tasks.length,
|
||||
active: 0,
|
||||
paused: 0,
|
||||
completed: 0,
|
||||
watchers: {
|
||||
active: 0,
|
||||
paused: 0,
|
||||
completed: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const task of args.tasks) {
|
||||
if (task.status === 'active') tasks.active += 1;
|
||||
if (task.status === 'paused') tasks.paused += 1;
|
||||
if (task.status === 'completed') tasks.completed += 1;
|
||||
|
||||
if (isWatchCiTask(task)) {
|
||||
if (task.status === 'active') tasks.watchers.active += 1;
|
||||
if (task.status === 'paused') tasks.watchers.paused += 1;
|
||||
if (task.status === 'completed') tasks.watchers.completed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const usageRows = collectUsageRows(args.snapshots);
|
||||
const usageFetchedAt =
|
||||
args.snapshots
|
||||
.map((snapshot) => snapshot.usageRowsFetchedAt)
|
||||
.filter((value): value is string => !!value)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
services,
|
||||
rooms,
|
||||
tasks,
|
||||
usage: {
|
||||
rows: usageRows,
|
||||
fetchedAt: usageFetchedAt,
|
||||
},
|
||||
inbox: collectInboxItems({
|
||||
snapshots: args.snapshots,
|
||||
tasks: args.tasks,
|
||||
pairedTasks: args.pairedTasks ?? [],
|
||||
createdAt: generatedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
1628
src/web-dashboard-server.test.ts
Normal file
1628
src/web-dashboard-server.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1659
src/web-dashboard-server.ts
Normal file
1659
src/web-dashboard-server.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ vi.mock('child_process', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
buildWorkspaceCommandEnvironment,
|
||||
buildWorkspaceScriptCommand,
|
||||
detectWorkspacePackageManager,
|
||||
ensureWorkspaceDependenciesInstalled,
|
||||
@@ -139,6 +140,13 @@ describe('workspace package manager helpers', () => {
|
||||
packageManager: 'pnpm',
|
||||
commandText: 'corepack pnpm install --frozen-lockfile',
|
||||
});
|
||||
expect(execFileSyncMock).toHaveBeenCalledWith(
|
||||
'corepack',
|
||||
['pnpm', 'install', '--frozen-lockfile'],
|
||||
expect.objectContaining({
|
||||
cwd: repoDir,
|
||||
}),
|
||||
);
|
||||
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
||||
|
||||
execFileSyncMock.mockClear();
|
||||
@@ -209,4 +217,59 @@ describe('workspace package manager helpers', () => {
|
||||
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => {
|
||||
const parentDir = path.join(tempRoot, 'parent');
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(parentDir, 'package.json'),
|
||||
JSON.stringify({ packageManager: 'bun@1.3.11' }),
|
||||
);
|
||||
|
||||
const repoDir = path.join(parentDir, 'child');
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'package.json'),
|
||||
JSON.stringify({ name: 'child', scripts: { typecheck: 'tsc --noEmit' } }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'pnpm-lock.yaml'),
|
||||
'lockfileVersion: 9.0\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
buildWorkspaceCommandEnvironment(repoDir, 'pnpm', { PATH: '/tmp/bin' }),
|
||||
).toMatchObject({
|
||||
PATH: '/tmp/bin',
|
||||
COREPACK_ENABLE_PROJECT_SPEC: '0',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an explicitly pinned pnpm workspace packageManager under a bun ancestor', () => {
|
||||
const parentDir = path.join(tempRoot, 'parent');
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(parentDir, 'package.json'),
|
||||
JSON.stringify({ packageManager: 'bun@1.3.11' }),
|
||||
);
|
||||
|
||||
const repoDir = path.join(parentDir, 'child');
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'child',
|
||||
packageManager: 'pnpm@10.11.0',
|
||||
scripts: { typecheck: 'tsc --noEmit' },
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'pnpm-lock.yaml'),
|
||||
'lockfileVersion: 9.0\n',
|
||||
);
|
||||
|
||||
expect(
|
||||
buildWorkspaceCommandEnvironment(repoDir, 'pnpm', { PATH: '/tmp/bin' }),
|
||||
).toEqual({ PATH: '/tmp/bin' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface WorkspaceDependencyInstallResult {
|
||||
commandText?: string;
|
||||
}
|
||||
|
||||
const COREPACK_PROJECT_SPEC_DISABLED = '0';
|
||||
const INSTALL_STATE_FILENAME = '.ejclaw-install-state.json';
|
||||
const NODE_MODULES_NOISE_ENTRIES = new Set([
|
||||
'.cache',
|
||||
@@ -117,6 +118,52 @@ function buildCorepackCommand(
|
||||
};
|
||||
}
|
||||
|
||||
function findNearestAncestorPackageManager(
|
||||
repoDir: string,
|
||||
): WorkspacePackageManager | null {
|
||||
let currentDir = path.dirname(path.resolve(repoDir));
|
||||
|
||||
while (true) {
|
||||
const packageManager = detectPackageManagerFromField(
|
||||
readPackageJsonMetadata(currentDir)?.packageManager,
|
||||
);
|
||||
if (packageManager) {
|
||||
return packageManager;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkspaceCommandEnvironment(
|
||||
repoDir: string,
|
||||
packageManager: WorkspacePackageManager,
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (packageManager !== 'pnpm') {
|
||||
return baseEnv;
|
||||
}
|
||||
|
||||
const localPackageManager = readPackageJsonMetadata(repoDir)?.packageManager;
|
||||
if (typeof localPackageManager === 'string' && localPackageManager.trim()) {
|
||||
return baseEnv;
|
||||
}
|
||||
|
||||
const ancestorPackageManager = findNearestAncestorPackageManager(repoDir);
|
||||
if (!ancestorPackageManager || ancestorPackageManager === packageManager) {
|
||||
return baseEnv;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseEnv,
|
||||
COREPACK_ENABLE_PROJECT_SPEC: COREPACK_PROJECT_SPEC_DISABLED,
|
||||
};
|
||||
}
|
||||
|
||||
function computeInstallFingerprint(repoDir: string): string | null {
|
||||
const packageJsonPath = path.join(repoDir, 'package.json');
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
@@ -359,6 +406,7 @@ export function ensureWorkspaceDependenciesInstalled(
|
||||
try {
|
||||
execFileSync(command.file, command.args, {
|
||||
cwd: repoDir,
|
||||
env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
@@ -420,6 +468,7 @@ export function detectPnpmStorePath(workspaceDir: string): string | null {
|
||||
try {
|
||||
const storePath = execFileSync(file, args, {
|
||||
cwd: workspaceDir,
|
||||
env: buildWorkspaceCommandEnvironment(workspaceDir, 'pnpm'),
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
|
||||
Reference in New Issue
Block a user