Merge EJClaw runtime hardening
Merge salvage hardening after CI pass: dist freshness guard, Codex usage fallback/path fix, IPC message_source_kind provenance, and workspace verification fixes.
This commit is contained in:
@@ -11,7 +11,8 @@
|
|||||||
"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: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 build:runners",
|
||||||
"build:runtime": "bun run build:all",
|
"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",
|
"start": "bun dist/index.js",
|
||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -41,3 +41,34 @@ Challenge the reviewer's reasoning. Point out logical gaps, over-engineering, sc
|
|||||||
- Implementation, commits, and pushes require agreement from both sides. Either can veto
|
- Implementation, commits, and pushes require agreement from both sides. Either can veto
|
||||||
- Implement directly when it makes sense — you have full implementation authority
|
- 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
|
- 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 workspace branch name MUST be `codex/owner/<group-folder>` at the moment your turn ends. If any other branch is checked out when the next message arrives, the entire room goes BLOCKED with "branch mismatch" and needs manual git recovery.
|
||||||
|
|
||||||
|
### Every turn, in order
|
||||||
|
|
||||||
|
1. **Start**: verify `git branch --show-current`. If it is not `codex/owner/<group-folder>`, check it out before doing anything else.
|
||||||
|
2. **Work**: feel free to create feature branches (`fix/...`, `feat/...`) while implementing.
|
||||||
|
3. **Before you finish the turn**:
|
||||||
|
- If you committed on a feature branch, merge it back into the owner branch (`git checkout codex/owner/<group-folder> && git merge --ff-only <feature-branch>`), then optionally delete the feature branch.
|
||||||
|
- If the feature branch is behind or diverged, use the appropriate merge/rebase to land the work on the owner branch.
|
||||||
|
- Confirm a clean end-state: `git branch --show-current` prints `codex/owner/<group-folder>` and `git status --short` is empty (or at least there is no merge conflict and no stray dirty state that the next turn cannot understand).
|
||||||
|
4. **Only then** emit your DONE / DONE_WITH_CONCERNS / BLOCKED / NEEDS_CONTEXT line and exit.
|
||||||
|
|
||||||
|
### Hard rules
|
||||||
|
|
||||||
|
- Never end a turn on a `fix/*` or `feat/*` branch.
|
||||||
|
- Never end a turn with unresolved merge conflicts.
|
||||||
|
- Never leave the workspace in detached-HEAD or rebase-in-progress 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.
|
||||||
|
|||||||
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
|
||||||
@@ -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', () => {
|
describe('codex-token-rotation d7 ≥ 100% auto-skip', () => {
|
||||||
let tempHome: string;
|
let tempHome: string;
|
||||||
|
|
||||||
@@ -142,4 +155,46 @@ describe('codex-token-rotation d7 ≥ 100% auto-skip', () => {
|
|||||||
'Failed to persist Codex rotation state',
|
'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;
|
let initialized = false;
|
||||||
|
|
||||||
const ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
|
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 {
|
export function initCodexTokenRotation(): void {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
if (!fs.existsSync(ACCOUNTS_DIR)) {
|
const hasAccountsDir = fs.existsSync(ACCOUNTS_DIR);
|
||||||
logger.info(
|
const dirs = hasAccountsDir
|
||||||
{ dir: ACCOUNTS_DIR },
|
? fs
|
||||||
'Codex accounts dir not found, skipping',
|
.readdirSync(ACCOUNTS_DIR)
|
||||||
);
|
.filter((d) => /^\d+$/.test(d))
|
||||||
return;
|
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||||
}
|
: [];
|
||||||
|
|
||||||
const dirs = fs
|
if (!hasAccountsDir) {
|
||||||
.readdirSync(ACCOUNTS_DIR)
|
logger.info({ dir: ACCOUNTS_DIR }, 'Codex accounts dir not found');
|
||||||
.filter((d) => /^\d+$/.test(d))
|
}
|
||||||
.sort((a, b) => parseInt(a) - parseInt(b));
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const authPath = path.join(ACCOUNTS_DIR, dir, 'auth.json');
|
const authPath = path.join(ACCOUNTS_DIR, dir, 'auth.json');
|
||||||
if (!fs.existsSync(authPath)) continue;
|
if (!fs.existsSync(authPath)) continue;
|
||||||
|
loadCodexAccount(authPath, `account-${dir}`);
|
||||||
|
}
|
||||||
|
|
||||||
const data = readJsonFile<{
|
if (dirs.length === 0 && fs.existsSync(DEFAULT_AUTH_PATH)) {
|
||||||
tokens?: { account_id?: string; id_token?: string };
|
if (loadCodexAccount(DEFAULT_AUTH_PATH, 'default-account')) {
|
||||||
}>(authPath);
|
logger.info(
|
||||||
if (!data) {
|
{ authPath: DEFAULT_AUTH_PATH },
|
||||||
logger.warn({ authPath }, 'Failed to parse codex account auth.json');
|
'Codex accounts dir absent/empty; using ~/.codex/auth.json fallback',
|
||||||
continue;
|
);
|
||||||
}
|
}
|
||||||
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();
|
if (accounts.length > 1) loadCodexState();
|
||||||
@@ -236,8 +253,14 @@ export function reloadCodexStateFromDisk(): void {
|
|||||||
|
|
||||||
/** Get the auth.json path for the current active account. */
|
/** Get the auth.json path for the current active account. */
|
||||||
export function getActiveCodexAuthPath(): string | null {
|
export function getActiveCodexAuthPath(): string | null {
|
||||||
|
return getCodexAuthPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCodexAuthPath(
|
||||||
|
accountIndex: number = currentIndex,
|
||||||
|
): string | null {
|
||||||
if (accounts.length === 0) return null;
|
if (accounts.length === 0) return null;
|
||||||
return accounts[currentIndex]?.authPath ?? null;
|
return accounts[accountIndex]?.authPath ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectCodexRotationTrigger(
|
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 {
|
import {
|
||||||
getAllCodexAccounts,
|
getAllCodexAccounts,
|
||||||
|
getCodexAuthPath,
|
||||||
updateCodexAccountUsage,
|
updateCodexAccountUsage,
|
||||||
} from './codex-token-rotation.js';
|
} from './codex-token-rotation.js';
|
||||||
import { formatResetRemaining, type UsageRow } from './dashboard-usage-rows.js';
|
import { formatResetRemaining, type UsageRow } from './dashboard-usage-rows.js';
|
||||||
@@ -27,11 +28,26 @@ export interface CodexUsageRefreshResult {
|
|||||||
fetchedAt: string | null;
|
fetchedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
|
|
||||||
|
|
||||||
/** Full scan interval — exported so the orchestrator can schedule it. */
|
/** Full scan interval — exported so the orchestrator can schedule it. */
|
||||||
export const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour
|
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(
|
export async function fetchCodexUsage(
|
||||||
codexHomeOverride?: string,
|
codexHomeOverride?: string,
|
||||||
): Promise<CodexRateLimit[] | null> {
|
): Promise<CodexRateLimit[] | null> {
|
||||||
@@ -59,11 +75,9 @@ export async function fetchCodexUsage(
|
|||||||
|
|
||||||
const spawnEnv: Record<string, string> = {
|
const spawnEnv: Record<string, string> = {
|
||||||
...(process.env as Record<string, string>),
|
...(process.env as Record<string, string>),
|
||||||
PATH: [
|
PATH: [...getPreferredCodexPathEntries(), process.env.PATH || '']
|
||||||
path.dirname(process.execPath),
|
.filter(Boolean)
|
||||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
.join(path.delimiter),
|
||||||
process.env.PATH || '',
|
|
||||||
].join(':'),
|
|
||||||
};
|
};
|
||||||
if (codexHomeOverride) {
|
if (codexHomeOverride) {
|
||||||
spawnEnv.CODEX_HOME = codexHomeOverride;
|
spawnEnv.CODEX_HOME = codexHomeOverride;
|
||||||
@@ -243,8 +257,8 @@ export async function refreshAllCodexAccountUsage(): Promise<CodexUsageRefreshRe
|
|||||||
|
|
||||||
let anySuccess = false;
|
let anySuccess = false;
|
||||||
for (const acct of codexAccounts) {
|
for (const acct of codexAccounts) {
|
||||||
const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(acct.index + 1));
|
const accountDir = getCodexHomeForAccount(acct.index);
|
||||||
if (!fs.existsSync(accountDir)) continue;
|
if (!accountDir) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const usage = await fetchCodexUsage(accountDir);
|
const usage = await fetchCodexUsage(accountDir);
|
||||||
@@ -281,8 +295,8 @@ export async function refreshActiveCodexUsage(): Promise<CodexUsageRefreshResult
|
|||||||
return { rows: buildCodexUsageRowsFromState(), fetchedAt: null };
|
return { rows: buildCodexUsageRowsFromState(), fetchedAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(active.index + 1));
|
const accountDir = getCodexHomeForAccount(active.index);
|
||||||
if (!fs.existsSync(accountDir)) {
|
if (!accountDir) {
|
||||||
return { rows: buildCodexUsageRowsFromState(), fetchedAt: null };
|
return { rows: buildCodexUsageRowsFromState(), fetchedAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ export function applyBaseSchema(database: Database): void {
|
|||||||
seq INTEGER,
|
seq INTEGER,
|
||||||
is_from_me INTEGER,
|
is_from_me INTEGER,
|
||||||
is_bot_message INTEGER DEFAULT 0,
|
is_bot_message INTEGER DEFAULT 0,
|
||||||
|
message_source_kind TEXT NOT NULL DEFAULT 'human',
|
||||||
PRIMARY KEY (id, chat_jid),
|
PRIMARY KEY (id, chat_jid),
|
||||||
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function getExpectedSchemaMigrations(): Array<{
|
|||||||
{ version: 10, name: 'paired_turn_provenance_upgrade' },
|
{ version: 10, name: 'paired_turn_provenance_upgrade' },
|
||||||
{ version: 11, name: 'owner_failure_count' },
|
{ version: 11, name: 'owner_failure_count' },
|
||||||
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
|
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
|
||||||
|
{ version: 13, name: 'message_source_kind' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
|
import {
|
||||||
|
inferMessageSourceKindFromBotFlag,
|
||||||
|
normalizeMessageSourceKind,
|
||||||
|
} from '../message-source.js';
|
||||||
import { NewMessage } from '../types.js';
|
import { NewMessage } from '../types.js';
|
||||||
|
|
||||||
export interface ChatInfo {
|
export interface ChatInfo {
|
||||||
@@ -14,12 +18,18 @@ function normalizeMessageRow(
|
|||||||
row: NewMessage & {
|
row: NewMessage & {
|
||||||
is_from_me?: boolean | number;
|
is_from_me?: boolean | number;
|
||||||
is_bot_message?: boolean | number;
|
is_bot_message?: boolean | number;
|
||||||
|
message_source_kind?: unknown;
|
||||||
},
|
},
|
||||||
): NewMessage {
|
): NewMessage {
|
||||||
|
const isBotMessage = !!row.is_bot_message;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
is_from_me: !!row.is_from_me,
|
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),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,15 +116,16 @@ export function storeMessageInDatabase(
|
|||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages (
|
`INSERT INTO messages (
|
||||||
id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message
|
id, chat_jid, sender, sender_name, content, timestamp, seq, is_from_me, is_bot_message, message_source_kind
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id, chat_jid) DO UPDATE SET
|
ON CONFLICT(id, chat_jid) DO UPDATE SET
|
||||||
sender = excluded.sender,
|
sender = excluded.sender,
|
||||||
sender_name = excluded.sender_name,
|
sender_name = excluded.sender_name,
|
||||||
content = excluded.content,
|
content = excluded.content,
|
||||||
timestamp = excluded.timestamp,
|
timestamp = excluded.timestamp,
|
||||||
is_from_me = excluded.is_from_me,
|
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(
|
.run(
|
||||||
msg.id,
|
msg.id,
|
||||||
@@ -126,6 +137,10 @@ export function storeMessageInDatabase(
|
|||||||
seq,
|
seq,
|
||||||
msg.is_from_me ? 1 : 0,
|
msg.is_from_me ? 1 : 0,
|
||||||
msg.is_bot_message ? 1 : 0,
|
msg.is_bot_message ? 1 : 0,
|
||||||
|
normalizeMessageSourceKind(
|
||||||
|
msg.message_source_kind,
|
||||||
|
inferMessageSourceKindFromBotFlag(msg.is_bot_message),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -142,7 +157,7 @@ export function getNewMessagesFromDatabase(
|
|||||||
const placeholders = jids.map(() => '?').join(',');
|
const placeholders = jids.map(() => '?').join(',');
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
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
|
FROM messages
|
||||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||||
AND content NOT LIKE ?
|
AND content NOT LIKE ?
|
||||||
@@ -178,7 +193,7 @@ export function getMessagesSinceFromDatabase(
|
|||||||
): NewMessage[] {
|
): NewMessage[] {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
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
|
FROM messages
|
||||||
WHERE chat_jid = ? AND timestamp > ?
|
WHERE chat_jid = ? AND timestamp > ?
|
||||||
AND content NOT LIKE ?
|
AND content NOT LIKE ?
|
||||||
@@ -238,7 +253,7 @@ export function getNewMessagesBySeqFromDatabase(
|
|||||||
|
|
||||||
const placeholders = jids.map(() => '?').join(',');
|
const placeholders = jids.map(() => '?').join(',');
|
||||||
const sql = `
|
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
|
FROM messages
|
||||||
WHERE seq > ? AND chat_jid IN (${placeholders})
|
WHERE seq > ? AND chat_jid IN (${placeholders})
|
||||||
AND content NOT LIKE ?
|
AND content NOT LIKE ?
|
||||||
@@ -273,7 +288,7 @@ export function getMessagesSinceSeqFromDatabase(
|
|||||||
): NewMessage[] {
|
): NewMessage[] {
|
||||||
const sinceSeq = normalizeSeqCursor(sinceSeqCursor);
|
const sinceSeq = normalizeSeqCursor(sinceSeqCursor);
|
||||||
const sql = `
|
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
|
FROM messages
|
||||||
WHERE chat_jid = ? AND seq > ?
|
WHERE chat_jid = ? AND seq > ?
|
||||||
AND content NOT LIKE ?
|
AND content NOT LIKE ?
|
||||||
@@ -300,7 +315,7 @@ export function getRecentChatMessagesFromDatabase(
|
|||||||
): NewMessage[] {
|
): NewMessage[] {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
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
|
FROM messages
|
||||||
WHERE chat_jid = ?
|
WHERE chat_jid = ?
|
||||||
AND content != '' AND content IS NOT NULL
|
AND content != '' AND content IS NOT NULL
|
||||||
|
|||||||
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}
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-
|
|||||||
import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-provenance-upgrade.js';
|
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 { 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 { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
|
||||||
|
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
|
||||||
import type {
|
import type {
|
||||||
SchemaMigrationArgs,
|
SchemaMigrationArgs,
|
||||||
SchemaMigrationDefinition,
|
SchemaMigrationDefinition,
|
||||||
@@ -32,6 +33,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
|||||||
PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION,
|
PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION,
|
||||||
OWNER_FAILURE_COUNT_MIGRATION,
|
OWNER_FAILURE_COUNT_MIGRATION,
|
||||||
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
|
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
|
||||||
|
MESSAGE_SOURCE_KIND_MIGRATION,
|
||||||
];
|
];
|
||||||
|
|
||||||
function ensureSchemaMigrationsTable(database: Database): void {
|
function ensureSchemaMigrationsTable(database: Database): void {
|
||||||
|
|||||||
49
src/index.ts
49
src/index.ts
@@ -61,6 +61,10 @@ import {
|
|||||||
hasAvailableClaudeToken,
|
hasAvailableClaudeToken,
|
||||||
initTokenRotation,
|
initTokenRotation,
|
||||||
} from './token-rotation.js';
|
} from './token-rotation.js';
|
||||||
|
import {
|
||||||
|
isBotMessageSourceKind,
|
||||||
|
resolveInjectedMessageSourceKind,
|
||||||
|
} from './message-source.js';
|
||||||
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
|
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
|
||||||
|
|
||||||
export function isTerminalStatusMessage(text: string): boolean {
|
export function isTerminalStatusMessage(text: string): boolean {
|
||||||
@@ -412,6 +416,51 @@ async function main(): Promise<void> {
|
|||||||
queue.noteDirectTerminalDelivery(jid, senderRole, text);
|
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,
|
nudgeScheduler: nudgeSchedulerLoop,
|
||||||
roomBindings: runtimeState.getRoomBindings,
|
roomBindings: runtimeState.getRoomBindings,
|
||||||
assignRoom: runtimeState.assignRoomForIpc,
|
assignRoom: runtimeState.assignRoomForIpc,
|
||||||
|
|||||||
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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', () => {
|
it('computes a stable snapshot over the readable workspace inputs', () => {
|
||||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
||||||
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import path from 'path';
|
|||||||
|
|
||||||
import { TIMEZONE } from './config.js';
|
import { TIMEZONE } from './config.js';
|
||||||
import {
|
import {
|
||||||
|
buildWorkspaceCommandEnvironment,
|
||||||
buildWorkspaceScriptCommand,
|
buildWorkspaceScriptCommand,
|
||||||
ensureWorkspaceDependenciesInstalled,
|
ensureWorkspaceDependenciesInstalled,
|
||||||
hasInstalledNodeModules,
|
hasInstalledNodeModules,
|
||||||
|
type WorkspacePackageManager,
|
||||||
} from './workspace-package-manager.js';
|
} from './workspace-package-manager.js';
|
||||||
import { computeVerificationSnapshotId } from '../shared/verification-snapshot.js';
|
import { computeVerificationSnapshotId } from '../shared/verification-snapshot.js';
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ export interface VerificationSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface VerificationCommandSpec {
|
interface VerificationCommandSpec {
|
||||||
|
packageManager: WorkspacePackageManager;
|
||||||
file: string;
|
file: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
commandText: string;
|
commandText: string;
|
||||||
@@ -111,6 +114,7 @@ export function buildVerificationCommand(
|
|||||||
: 'lint';
|
: 'lint';
|
||||||
const command = buildWorkspaceScriptCommand(repoDir, scriptName);
|
const command = buildWorkspaceScriptCommand(repoDir, scriptName);
|
||||||
return {
|
return {
|
||||||
|
packageManager: command.packageManager,
|
||||||
file: command.file,
|
file: command.file,
|
||||||
args: command.args,
|
args: command.args,
|
||||||
commandText: command.commandText,
|
commandText: command.commandText,
|
||||||
@@ -334,7 +338,11 @@ export async function runVerificationRequest(
|
|||||||
command.args,
|
command.args,
|
||||||
{
|
{
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
env: directExecution.env,
|
env: buildWorkspaceCommandEnvironment(
|
||||||
|
repoDir,
|
||||||
|
command.packageManager,
|
||||||
|
directExecution.env,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const afterSnapshot = computeVerificationSnapshot(repoDir);
|
const afterSnapshot = computeVerificationSnapshot(repoDir);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('child_process', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildWorkspaceCommandEnvironment,
|
||||||
buildWorkspaceScriptCommand,
|
buildWorkspaceScriptCommand,
|
||||||
detectWorkspacePackageManager,
|
detectWorkspacePackageManager,
|
||||||
ensureWorkspaceDependenciesInstalled,
|
ensureWorkspaceDependenciesInstalled,
|
||||||
@@ -139,6 +140,13 @@ describe('workspace package manager helpers', () => {
|
|||||||
packageManager: 'pnpm',
|
packageManager: 'pnpm',
|
||||||
commandText: 'corepack pnpm install --frozen-lockfile',
|
commandText: 'corepack pnpm install --frozen-lockfile',
|
||||||
});
|
});
|
||||||
|
expect(execFileSyncMock).toHaveBeenCalledWith(
|
||||||
|
'corepack',
|
||||||
|
['pnpm', 'install', '--frozen-lockfile'],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: repoDir,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
||||||
|
|
||||||
execFileSyncMock.mockClear();
|
execFileSyncMock.mockClear();
|
||||||
@@ -209,4 +217,59 @@ describe('workspace package manager helpers', () => {
|
|||||||
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
||||||
expect(execFileSyncMock).not.toHaveBeenCalled();
|
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;
|
commandText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COREPACK_PROJECT_SPEC_DISABLED = '0';
|
||||||
const INSTALL_STATE_FILENAME = '.ejclaw-install-state.json';
|
const INSTALL_STATE_FILENAME = '.ejclaw-install-state.json';
|
||||||
const NODE_MODULES_NOISE_ENTRIES = new Set([
|
const NODE_MODULES_NOISE_ENTRIES = new Set([
|
||||||
'.cache',
|
'.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 {
|
function computeInstallFingerprint(repoDir: string): string | null {
|
||||||
const packageJsonPath = path.join(repoDir, 'package.json');
|
const packageJsonPath = path.join(repoDir, 'package.json');
|
||||||
if (!fs.existsSync(packageJsonPath)) {
|
if (!fs.existsSync(packageJsonPath)) {
|
||||||
@@ -359,6 +406,7 @@ export function ensureWorkspaceDependenciesInstalled(
|
|||||||
try {
|
try {
|
||||||
execFileSync(command.file, command.args, {
|
execFileSync(command.file, command.args, {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager),
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
@@ -420,6 +468,7 @@ export function detectPnpmStorePath(workspaceDir: string): string | null {
|
|||||||
try {
|
try {
|
||||||
const storePath = execFileSync(file, args, {
|
const storePath = execFileSync(file, args, {
|
||||||
cwd: workspaceDir,
|
cwd: workspaceDir,
|
||||||
|
env: buildWorkspaceCommandEnvironment(workspaceDir, 'pnpm'),
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
|||||||
Reference in New Issue
Block a user