mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(amr): resolve run model catalog from cache instead of a blocking per-run vela model list (#4075)
* fix(amr): resolve run model catalog from the shared cache instead of a blocking per-run `vela model list`
Packaged AMR runs failed with "AMR model … is not available from Vela.
Refresh the AMR model list, choose a supported model, and retry this run"
even for a logged-in user who had a real model selected.
Root cause: the AMR run preflight ran a fresh, blocking
`vela model list` (the authoritative remote catalog) on every turn and
fail-closed the run whenever that single call timed out or errored.
That call needs network reachability to the AMR gateway AND `$HOME`
(the offline `vela model preset` / `--version` calls need neither),
has a 10s ceiling, and only retries a narrow set of network errors.
Under CorpLink/飞连 the round-trip routinely approached the timeout, so
runs died at preflight — while the model picker still showed models
(seeded from the offline preset), making AMR look selectable but unusable.
Fix (daemon):
- Preflight now resolves the catalog through the same AmrModelLoadingCache
the UI's /api/amr/models endpoint uses: a cached authoritative
`vela model list` when hot, otherwise the offline `vela model preset`
seed while a remote refresh runs in the background. The blocking remote
probe is off the per-run hot path; a transient failure degrades to
preset instead of fail-closing. vela's own `session/set_model` stays
the final gate for a genuinely unsupported model.
- The previously-silent `catch {}` around the catalog probe now logs, so
the failure is no longer invisible in logs/diagnostics.
- Backfill `$HOME` in the AMR spawn env: `execAgentFile` replaces the
child environment wholesale, and `vela model list` exits non-zero with
"$HOME is not defined" when it is missing (preset/--version do not),
which silently decapitated the catalog probe.
- Raise the cache's remote-refresh interval from 60s to 10min: the
catalog changes on the order of days, cached entries are returned
instantly, and the per-run path now reads this cache.
Diagnostics: the export bundle now also carries the coding-agent CLIs'
own logs (claude / codex / opencode / amr) plus recent per-run
events.jsonl, via shared builders in @open-design/diagnostics used by
BOTH the daemon HTTP export and the desktop IPC export (the latter,
which users actually trigger, previously shipped neither run logs nor
agent logs). Secret-bearing files (~/.amr/config.json, ~/.codex/auth.json)
are never swept; only *.log files, tail-bounded and redacted.
* fix(diagnostics): resolve AMR OpenCode log dir from the effective OPENCODE_TEST_HOME
The AMR CLI log sweep hardcoded `<dataDir>/amr/opencode-home/...`, which
misses the provider logs as soon as a user sets
`agentCliEnv.amr.OPENCODE_TEST_HOME` (a supported app-config override that
spawnEnvForAgent preserves) — the run writes logs elsewhere while the
bundle keeps looking in the default path.
- buildAgentCliLogSources now takes an explicit `amrOpenCodeHome`; it
sweeps `<amrOpenCodeHome>/.local/share/opencode/log` and falls back to
the `<dataDir>/amr/opencode-home` default when not provided.
- The daemon export resolves the effective home through the live
`spawnEnvForAgent('amr', …)` (honoring the app-config override) so the
two cannot drift.
- The desktop export reads the override from app-config directly (it may
not import daemon internals) with leading-`~` expansion, falling back to
the default otherwise.
* fix(diagnostics): honor CLAUDE_CONFIG_DIR / CODEX_HOME overrides in the agent-log sweep
Same class as the AMR OpenCode-home fix: `agentCliEnv.claude.CLAUDE_CONFIG_DIR`
and `agentCliEnv.codex.CODEX_HOME` are supported app-config overrides
(`app-config.ts`), but the collector hardcoded `~/.claude` and `~/.codex/log`,
so a user who relocates either CLI home would have those provider logs dropped
from the bundle.
- buildAgentCliLogSources now takes `claudeConfigDir` / `codexHome`,
falling back to `~/.claude` and `~/.codex` when not provided.
- The daemon export resolves all three homes (amr/claude/codex) through the
live spawnEnvForAgent so they cannot drift from the spawn path.
- The desktop export reads the overrides from app-config directly with `~`
expansion.
* fix(diagnostics): resolve OpenCode XDG_DATA_HOME from the effective spawn env
Sandbox mode (sandbox-mode.ts) rewrites XDG_DATA_HOME to a sandbox root, and
OpenCode resolves its data/log dir from XDG_DATA_HOME. The export was passing
the host process's XDG_DATA_HOME, so a sandboxed runtime would miss the
OpenCode CLI logs. Resolve it from `spawnEnvForAgent('opencode', …)` (which
reapplies sandbox env) alongside the amr/claude/codex homes; falls back to the
host value when unavailable. Sandbox is opt-in (OD_SANDBOX_MODE) and off in
packaged/dev runtimes, but this keeps the resolution consistent and correct.
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import {
|
||||
buildAgentCliLogSources,
|
||||
buildDiagnosticsZip,
|
||||
buildRunEventLogSources,
|
||||
DIAGNOSTICS_CONTENT_TYPE,
|
||||
DIAGNOSTICS_FILENAME_PREFIX,
|
||||
diagnosticsFileName,
|
||||
@@ -24,6 +25,56 @@ import {
|
||||
} from '@open-design/sidecar';
|
||||
|
||||
import { readCurrentAppVersionInfo } from './app-version.js';
|
||||
import { agentCliEnvForAgent, readAppConfig } from './app-config.js';
|
||||
import { spawnEnvForAgent } from './agents.js';
|
||||
|
||||
interface ResolvedAgentHomes {
|
||||
amrOpenCodeHome: string | null;
|
||||
claudeConfigDir: string | null;
|
||||
codexHome: string | null;
|
||||
openCodeXdgDataHome: string | null;
|
||||
}
|
||||
|
||||
// Resolve each agent CLI's effective home (OPENCODE_TEST_HOME / CLAUDE_CONFIG_DIR
|
||||
// / CODEX_HOME) exactly as a real run would, by running the live
|
||||
// `spawnEnvForAgent` resolver over the user's app-config overrides. This makes
|
||||
// the diagnostics sweep honor `agentCliEnv.<agent>.*` relocations instead of
|
||||
// only looking under the hardcoded defaults, and cannot drift from the spawn
|
||||
// path. Returns nulls on any failure; the collector then falls back to defaults.
|
||||
async function resolveAgentHomes(dataDir: string | null | undefined): Promise<ResolvedAgentHomes> {
|
||||
const empty: ResolvedAgentHomes = {
|
||||
amrOpenCodeHome: null,
|
||||
claudeConfigDir: null,
|
||||
codexHome: null,
|
||||
openCodeXdgDataHome: null,
|
||||
};
|
||||
if (!dataDir) return empty;
|
||||
try {
|
||||
const appConfig = await readAppConfig(dataDir);
|
||||
const envFor = (agentId: string) =>
|
||||
spawnEnvForAgent(
|
||||
agentId,
|
||||
{ ...process.env, OD_DATA_DIR: dataDir },
|
||||
agentCliEnvForAgent(appConfig.agentCliEnv, agentId),
|
||||
);
|
||||
const clean = (value: string | undefined): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
return {
|
||||
amrOpenCodeHome: clean(envFor('amr').OPENCODE_TEST_HOME),
|
||||
claudeConfigDir: clean(envFor('claude').CLAUDE_CONFIG_DIR),
|
||||
codexHome: clean(envFor('codex').CODEX_HOME),
|
||||
// OpenCode resolves its data/log dir from XDG_DATA_HOME; sandbox mode
|
||||
// rewrites that (sandbox-mode.ts), so read the EFFECTIVE value from the
|
||||
// opencode spawn env rather than the host's, or the sweep misses the
|
||||
// logs in a sandboxed runtime.
|
||||
openCodeXdgDataHome: clean(envFor('opencode').XDG_DATA_HOME),
|
||||
};
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiagnosticsHandlerOptions {
|
||||
/** Sidecar runtime context, present when daemon is launched via tools-dev or packaged sidecar. */
|
||||
@@ -32,11 +83,11 @@ export interface DiagnosticsHandlerOptions {
|
||||
projectRoot: string;
|
||||
/** Directory containing per-run event logs at <runsDir>/<runId>/events.jsonl. */
|
||||
runsDir?: string | null;
|
||||
/** Open Design data dir (OD_DATA_DIR), used to locate the AMR OpenCode home. */
|
||||
dataDir?: string | null;
|
||||
}
|
||||
|
||||
const TAIL_BYTES_PER_LOG = 4 * 1024 * 1024;
|
||||
const TAIL_BYTES_PER_RUN_EVENT_LOG = 2 * 1024 * 1024;
|
||||
const MAX_RUN_EVENT_LOGS = 20;
|
||||
|
||||
function safeUsername(): string | undefined {
|
||||
try {
|
||||
@@ -93,48 +144,25 @@ function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp> | n
|
||||
return sources;
|
||||
}
|
||||
|
||||
async function buildRunEventLogSources(runsDir: string | null | undefined): Promise<LogSource[]> {
|
||||
if (!runsDir) return [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(runsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: Array<{ runId: string; absolutePath: string; mtimeMs: number }> = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(entry.name)) continue;
|
||||
const absolutePath = join(runsDir, entry.name, 'events.jsonl');
|
||||
try {
|
||||
const info = await stat(absolutePath);
|
||||
if (!info.isFile()) continue;
|
||||
candidates.push({ runId: entry.name, absolutePath, mtimeMs: info.mtimeMs });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates.slice(0, MAX_RUN_EVENT_LOGS).map(({ runId, absolutePath }) => ({
|
||||
name: `runs/${runId}/events.jsonl`,
|
||||
absolutePath,
|
||||
kind: 'text',
|
||||
tailBytes: TAIL_BYTES_PER_RUN_EVENT_LOG,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createDiagnosticsExportHandler(options: DiagnosticsHandlerOptions): RequestHandler {
|
||||
return async (_req, res) => {
|
||||
try {
|
||||
const versionInfo = await readCurrentAppVersionInfo().catch(() => null);
|
||||
const home = homedir();
|
||||
const agentHomes = await resolveAgentHomes(options.dataDir);
|
||||
const sources = [
|
||||
...buildSidecarLogSources(options.runtime),
|
||||
...(await buildRunEventLogSources(options.runsDir)),
|
||||
...(await buildAgentCliLogSources({
|
||||
homeDir: home,
|
||||
dataDir: options.dataDir ?? null,
|
||||
amrOpenCodeHome: agentHomes.amrOpenCodeHome,
|
||||
claudeConfigDir: agentHomes.claudeConfigDir,
|
||||
codexHome: agentHomes.codexHome,
|
||||
xdgDataHome: agentHomes.openCodeXdgDataHome ?? process.env.XDG_DATA_HOME ?? null,
|
||||
})),
|
||||
];
|
||||
const username = safeUsername();
|
||||
const home = homedir();
|
||||
|
||||
const result = await buildDiagnosticsZip({
|
||||
context: {
|
||||
|
||||
@@ -17,7 +17,14 @@ type CacheState = {
|
||||
lastRemoteError: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_REMOTE_REFRESH_INTERVAL_MS = 60_000;
|
||||
// The AMR model catalog changes rarely (new models land on the order of days),
|
||||
// and a cached remote list is returned immediately while a refresh runs in the
|
||||
// background — `get()` never blocks on the network when a cached entry exists.
|
||||
// The per-run preflight now also reads this cache, so a tight interval would
|
||||
// spawn `vela model list` far more often than the catalog actually changes.
|
||||
// Refresh at most once every 10 minutes per cache key; callers always get the
|
||||
// last-known catalog instantly in between.
|
||||
const DEFAULT_REMOTE_REFRESH_INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error ?? 'unknown error');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -96,6 +97,19 @@ export function spawnEnvForAgent(
|
||||
if (agentId === 'amr') {
|
||||
Object.assign(env, amrVelaProfileEnv(env));
|
||||
Object.assign(env, amrAnalyticsIdentityEnv(env));
|
||||
// `execAgentFile` REPLACES the child environment (execFile with `env`
|
||||
// set), so anything missing here is genuinely absent for vela. `vela model
|
||||
// list` resolves its config home up front and exits non-zero with
|
||||
// "$HOME is not defined" when HOME is unset — while `vela model preset`
|
||||
// and `vela --version` do not need it. A packaged daemon spawned with a
|
||||
// stripped env (or any caller that did not forward HOME) would therefore
|
||||
// detect AMR and seed the picker from preset, yet fail every run's remote
|
||||
// catalog probe. Backfill HOME from the OS so the authoritative catalog
|
||||
// call is never silently decapitated by a missing home dir.
|
||||
if (!env.HOME?.trim()) {
|
||||
const home = os.homedir();
|
||||
if (home) env.HOME = home;
|
||||
}
|
||||
// Identify Open Design as the host so the vela CLI tags its command +
|
||||
// model_request analytics with source=open_design (revenue attribution).
|
||||
// Not PII (unlike the installation id above), so set it regardless of the
|
||||
|
||||
+66
-36
@@ -5535,6 +5535,7 @@ export async function startServer({
|
||||
runtime,
|
||||
projectRoot: PROJECT_ROOT,
|
||||
runsDir: path.join(RUNTIME_DATA_DIR, 'runs'),
|
||||
dataDir: RUNTIME_DATA_DIR,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -12247,16 +12248,39 @@ export async function startServer({
|
||||
agentLaunch,
|
||||
)
|
||||
: null;
|
||||
const amrModelScope = resolveAmrProfile(modelProbeEnv ?? process.env);
|
||||
// Resolve the AMR model catalog through the SAME shared cache the UI's
|
||||
// `/api/amr/models` endpoint serves (AmrModelLoadingCache): a cached
|
||||
// authoritative `vela model list` when it is hot, otherwise the offline
|
||||
// `vela model preset` seed while a remote refresh runs in the background.
|
||||
//
|
||||
// Why not a fresh `vela model list` per run: that authoritative call
|
||||
// needs network reachability to the AMR gateway AND `$HOME` (the offline
|
||||
// `preset`/`--version` calls need neither), takes up to ~10s, and only
|
||||
// retries a narrow set of network errors. Running it blocking on every
|
||||
// turn turned any transient gateway/timeout/HOME hiccup into a hard
|
||||
// "AMR model … is not available from Vela" — even for a logged-in user
|
||||
// who already picked a real model the picker surfaced from the preset
|
||||
// seed. Under CorpLink/飞连 the call routinely exceeded the timeout, so
|
||||
// AMR became unusable in packaged nightlies. Reusing the cache keeps that
|
||||
// blocking probe off the per-run hot path and degrades to preset instead
|
||||
// of fail-closing; vela's own `session/set_model` remains the final gate.
|
||||
let liveModels = [];
|
||||
try {
|
||||
liveModels =
|
||||
launchPath && typeof def.fetchModels === 'function'
|
||||
? ((await def.fetchModels(launchPath, modelProbeEnv)) ?? [])
|
||||
: [];
|
||||
} catch {
|
||||
const probe = await resolveAmrModelProbe();
|
||||
const catalog = await amrModelLoadingCache.get(probe.cacheKey, {
|
||||
fetchPreset: () => fetchVelaPresetModels(probe.launchPath, probe.env),
|
||||
fetchRemote: () => fetchVelaRemoteModelsWithRetry(probe.launchPath, probe.env),
|
||||
});
|
||||
liveModels = catalog.models ?? [];
|
||||
} catch (error) {
|
||||
// Do not swallow silently: a probe failure here is exactly what made
|
||||
// the packaged AMR breakage undiagnosable (the old `catch {}` left no
|
||||
// trace in any log or diagnostics bundle). Record it and degrade to the
|
||||
// remembered catalog below.
|
||||
console.warn('[amr] model catalog preflight probe failed', error);
|
||||
liveModels = [];
|
||||
}
|
||||
const amrModelScope = resolveAmrProfile(modelProbeEnv ?? process.env);
|
||||
const rememberedLiveModels = getRememberedLiveModels(def.id, amrModelScope);
|
||||
if (liveModels.length > 0) {
|
||||
rememberLiveModels(def.id, liveModels, amrModelScope);
|
||||
@@ -12265,12 +12289,26 @@ export async function startServer({
|
||||
const liveModelIds = new Set(
|
||||
liveModels.map((candidate) => candidate?.id).filter(Boolean),
|
||||
);
|
||||
// A request that came in as 'default'/empty is normally pre-resolved to a
|
||||
// concrete id via the agent-wide cached model order; if it still is not,
|
||||
// adopt the first catalog entry so the spawn layer always has a real id.
|
||||
const userAskedForDefault =
|
||||
typeof model !== 'string' ||
|
||||
!model.trim() ||
|
||||
model.trim().toLowerCase() === 'default';
|
||||
if (
|
||||
!safeModel ||
|
||||
safeModel === 'default' ||
|
||||
(userAskedForDefault && !liveModelIds.has(safeModel))
|
||||
) {
|
||||
safeModel = liveModels[0]?.id ?? safeModel ?? null;
|
||||
agentOptions.model = safeModel;
|
||||
}
|
||||
if (liveModelIds.size === 0) {
|
||||
// An empty AMR catalog usually means the user is signed out — `vela
|
||||
// models` returns 401 and the catch above leaves `liveModels` empty.
|
||||
// Surface AMR_AUTH_REQUIRED first so the chat shows the relogin
|
||||
// affordance; otherwise the user sees a misleading "choose a model"
|
||||
// when the real fix is to sign in.
|
||||
// The catalog is genuinely empty: even the offline preset seed could
|
||||
// not be read, which almost always means the user is signed out (`vela`
|
||||
// catalog calls 401) or the CLI is unrunnable. Prefer the relogin
|
||||
// affordance over a misleading "choose a model".
|
||||
if (def.id === 'amr') {
|
||||
const loginStatus = readVelaLoginStatus(
|
||||
modelProbeEnv ?? process.env,
|
||||
@@ -12286,37 +12324,29 @@ export async function startServer({
|
||||
return design.runs.finish(run, 'failed', 1, null);
|
||||
}
|
||||
}
|
||||
send('error', createAmrModelUnavailablePayload(safeModel, {
|
||||
reason: 'model_catalog_unavailable',
|
||||
}));
|
||||
return design.runs.finish(run, 'failed', 1, null);
|
||||
}
|
||||
// `safeModel` was pre-resolved via the agent-wide cached model order,
|
||||
// so a request that came in as 'default' (or empty) is already a
|
||||
// concrete id by this point — `safeModel === 'default'` is rarely true.
|
||||
// If the user actually asked for the agent default and the cached id no
|
||||
// longer appears in the FRESH catalog (e.g. the AMR Link catalog rolled
|
||||
// since `/api/agents` last responded), fall back to `liveModels[0]` from
|
||||
// the fresh probe instead of rejecting their run as `AMR_MODEL_UNAVAILABLE`.
|
||||
const userAskedForDefault =
|
||||
typeof model !== 'string' ||
|
||||
!model.trim() ||
|
||||
model.trim().toLowerCase() === 'default';
|
||||
if (
|
||||
!safeModel ||
|
||||
safeModel === 'default' ||
|
||||
(userAskedForDefault && !liveModelIds.has(safeModel))
|
||||
) {
|
||||
safeModel = liveModels[0]?.id ?? null;
|
||||
agentOptions.model = safeModel;
|
||||
}
|
||||
if (!safeModel || !liveModelIds.has(safeModel)) {
|
||||
// Logged in but no catalog at all AND no resolvable model: only now is
|
||||
// there nothing safe to forward, so surface the model error.
|
||||
if (!safeModel) {
|
||||
send('error', createAmrModelUnavailablePayload(safeModel, {
|
||||
reason: 'model_catalog_unavailable',
|
||||
}));
|
||||
return design.runs.finish(run, 'failed', 1, null);
|
||||
}
|
||||
// Otherwise fall through with the user's selected model and let vela's
|
||||
// `session/set_model` be the authoritative gate.
|
||||
} else if (!safeModel) {
|
||||
// Catalog known but we could not resolve any model id to forward.
|
||||
send('error', createAmrModelUnavailablePayload(
|
||||
typeof model === 'string' && model.trim() ? model : safeModel,
|
||||
{ availableModels: [...liveModelIds] },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', 1, null);
|
||||
}
|
||||
// NOTE: when the selected model is absent from the (possibly preset-only
|
||||
// or stale) catalog we intentionally do NOT fail-close. The cached/preset
|
||||
// catalog can lag the live one, and a logged-in user picked a concrete
|
||||
// id; vela rejects a truly unsupported model at `session/set_model` with
|
||||
// a precise error, which beats a pre-emptive block on a flaky metadata read.
|
||||
}
|
||||
|
||||
// Plain-streaming adapters that own a "continue most recent
|
||||
|
||||
@@ -329,12 +329,19 @@ process.exit(1);
|
||||
);
|
||||
});
|
||||
|
||||
it('retries transient AMR Link catalog failures before aborting startup', async () => {
|
||||
it('survives transient AMR Link catalog failures without aborting the run', async () => {
|
||||
// The run preflight resolves the AMR catalog through the shared
|
||||
// AmrModelLoadingCache, which degrades to the offline `vela model preset`
|
||||
// seed whenever the authoritative `vela model list` is momentarily
|
||||
// unavailable (and refreshes the remote catalog in the background). So a
|
||||
// transient catalog failure must NOT abort the run — the per-run path no
|
||||
// longer blocks on a synchronous `model list` retry loop.
|
||||
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
|
||||
const previousLinkUrl = process.env.VELA_LINK_URL;
|
||||
const stateFile = join(tmpdir(), `od-amr-model-retry-${randomUUID()}.json`);
|
||||
try {
|
||||
process.env.VELA_RUNTIME_KEY = 'fake-runtime-key';
|
||||
// Unique key so the shared model cache key is unique per test run.
|
||||
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
|
||||
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
|
||||
|
||||
await withFakeAgent(
|
||||
@@ -381,8 +388,13 @@ child.on('exit', (code, signal) => {
|
||||
expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
|
||||
expect(body).toContain('"type":"text_delta","delta":"vela."');
|
||||
expect(body).not.toContain('model_catalog_unavailable');
|
||||
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
|
||||
// The catalog probe runs at least once (remote attempted, then the
|
||||
// run proceeds from the preset seed). We no longer assert an exact
|
||||
// synchronous retry count: the remote retry/backoff now happens in
|
||||
// the cache's background refresh, not on the per-run hot path.
|
||||
const attempts = JSON.parse(readFileSync(stateFile, 'utf8')) as { attempts: number };
|
||||
expect(attempts.attempts).toBe(3);
|
||||
expect(attempts.attempts).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
@@ -394,6 +406,83 @@ child.on('exit', (code, signal) => {
|
||||
}
|
||||
});
|
||||
|
||||
it('proceeds with the AMR run via the cached/preset catalog when the live model list is unavailable', async () => {
|
||||
// Red spec for the packaged-nightly "AMR model the selected model is not
|
||||
// available from Vela" report: the run preflight used to do a fresh,
|
||||
// blocking `vela model list` (authoritative remote catalog) on EVERY run
|
||||
// and fail-close the run whenever that single call timed out / errored —
|
||||
// even though the user is logged in, the model picker already shows a
|
||||
// model (seeded from the offline `vela model preset`), and the selected
|
||||
// model is real. Under CorpLink/飞连 the remote call routinely exceeds the
|
||||
// 10s timeout, so a logged-in user with a valid model could not run AMR at
|
||||
// all. The fix reuses the shared AmrModelLoadingCache (cached remote when
|
||||
// hot, otherwise the offline preset seed) instead of a per-run blocking
|
||||
// remote probe, so a transient `model list` failure no longer kills the run.
|
||||
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
|
||||
const previousLinkUrl = process.env.VELA_LINK_URL;
|
||||
try {
|
||||
// A unique runtime key both marks the user as logged-in AND makes the
|
||||
// shared model cache key unique so this case never reuses another test's
|
||||
// cached remote catalog.
|
||||
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
|
||||
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
|
||||
|
||||
await withFakeAgent(
|
||||
'vela',
|
||||
`
|
||||
const { spawn } = require('node:child_process');
|
||||
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
|
||||
const args = process.argv.slice(2);
|
||||
// Simulate a persistently unreachable authoritative catalog (gateway
|
||||
// timeout / 飞连 congestion): every \`vela model list\` fails. \`model preset\`,
|
||||
// \`login\`, and \`agent run\` still delegate to the fixture, mirroring the real
|
||||
// CLI where the offline preset and the ACP run do not need the gateway.
|
||||
if (args[0] === 'model' && args[1] === 'list') {
|
||||
process.stderr.write('Get "https://amr-link.open-design.ai/v1/models": context deadline exceeded\\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const child = spawn(process.execPath, [fixture, ...args], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
`,
|
||||
async () => {
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'amr',
|
||||
message: 'hello',
|
||||
// Present in the preset seed (DEFAULT_MODEL_PRESET_JSON) but the
|
||||
// live `model list` is unavailable, so only the preset path can
|
||||
// surface it.
|
||||
model: 'glm-5.1',
|
||||
}),
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
// The run must NOT be fail-closed on the unavailable live catalog.
|
||||
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
|
||||
expect(body).not.toContain('model_catalog_unavailable');
|
||||
expect(body).not.toContain('is not available from Vela');
|
||||
// It must actually proceed into the ACP run and stream assistant text.
|
||||
expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
|
||||
expect(body).toContain('"type":"text_delta","delta":"vela."');
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (previousRuntimeKey == null) delete process.env.VELA_RUNTIME_KEY;
|
||||
else process.env.VELA_RUNTIME_KEY = previousRuntimeKey;
|
||||
if (previousLinkUrl == null) delete process.env.VELA_LINK_URL;
|
||||
else process.env.VELA_LINK_URL = previousLinkUrl;
|
||||
}
|
||||
});
|
||||
|
||||
it('allows plugin authoring to succeed when the requested generated-plugin artifacts exist before close', async () => {
|
||||
const projectId = `proj-plugin-authoring-success-${randomUUID()}`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir, userInfo } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
} from "@open-design/sidecar";
|
||||
import {
|
||||
DIAGNOSTICS_FILENAME_PREFIX,
|
||||
buildAgentCliLogSources,
|
||||
buildDiagnosticsZip,
|
||||
buildRunEventLogSources,
|
||||
diagnosticsFileName,
|
||||
type LogSource,
|
||||
} from "@open-design/diagnostics";
|
||||
@@ -40,6 +42,43 @@ function safeUsername(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function expandTilde(value: unknown): string | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") return homedir();
|
||||
if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
interface AgentHomeOverrides {
|
||||
amrOpenCodeHome: string | null;
|
||||
claudeConfigDir: string | null;
|
||||
codexHome: string | null;
|
||||
}
|
||||
|
||||
// Best-effort read of user `agentCliEnv.<agent>.*` home overrides
|
||||
// (`OPENCODE_TEST_HOME` / `CLAUDE_CONFIG_DIR` / `CODEX_HOME`) from the daemon's
|
||||
// app-config, so the log sweep targets the same homes a real run uses. The
|
||||
// daemon resolves these authoritatively via spawnEnvForAgent; the desktop main
|
||||
// process may not import daemon internals, so it reads the config file directly
|
||||
// and applies only leading-`~` expansion (overrides are effectively absolute).
|
||||
// Missing values fall back to the collector's defaults.
|
||||
async function readAgentHomeOverrides(dataDir: string): Promise<AgentHomeOverrides> {
|
||||
try {
|
||||
const raw = await readFile(join(dataDir, "app-config.json"), "utf8");
|
||||
const agentCliEnv = (JSON.parse(raw) as {
|
||||
agentCliEnv?: Record<string, Record<string, unknown> | undefined>;
|
||||
}).agentCliEnv;
|
||||
return {
|
||||
amrOpenCodeHome: expandTilde(agentCliEnv?.amr?.OPENCODE_TEST_HOME),
|
||||
claudeConfigDir: expandTilde(agentCliEnv?.claude?.CLAUDE_CONFIG_DIR),
|
||||
codexHome: expandTilde(agentCliEnv?.codex?.CODEX_HOME),
|
||||
};
|
||||
} catch {
|
||||
return { amrOpenCodeHome: null, claudeConfigDir: null, codexHome: null };
|
||||
}
|
||||
}
|
||||
|
||||
function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp>): LogSource[] {
|
||||
// In packaged builds `runtime.base` is `<namespaceRoot>/runtime`, so the log
|
||||
// tree lives a level UP at `<namespaceRoot>/logs`; `resolveRuntimeNamespaceRoot`
|
||||
@@ -108,6 +147,34 @@ export async function exportDiagnosticsToFile(
|
||||
}
|
||||
|
||||
try {
|
||||
// The packaged daemon writes its runtime data at `<namespaceRoot>/data`
|
||||
// (OD_DATA_DIR), with per-run event logs under `data/runs` and the
|
||||
// AMR-managed OpenCode home under `data/amr`. Derive both so this export
|
||||
// — the one users actually trigger from the desktop UI — carries the same
|
||||
// run/agent diagnostics the daemon HTTP export does, instead of only the
|
||||
// three sidecar logs.
|
||||
const namespaceRoot = resolveRuntimeNamespaceRoot({
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
runtime,
|
||||
runtimeMode: SIDECAR_MODES.RUNTIME,
|
||||
});
|
||||
const dataDir = join(namespaceRoot, "data");
|
||||
const runsDir = join(dataDir, "runs");
|
||||
// Honor user `agentCliEnv.<agent>.*` home overrides so relocated CLI homes
|
||||
// are not missed; absent values fall back to the collector's defaults.
|
||||
const agentHomes = await readAgentHomeOverrides(dataDir);
|
||||
const sources: LogSource[] = [
|
||||
...buildSidecarLogSources(runtime),
|
||||
...(await buildRunEventLogSources(runsDir)),
|
||||
...(await buildAgentCliLogSources({
|
||||
homeDir: homedir(),
|
||||
dataDir,
|
||||
amrOpenCodeHome: agentHomes.amrOpenCodeHome,
|
||||
claudeConfigDir: agentHomes.claudeConfigDir,
|
||||
codexHome: agentHomes.codexHome,
|
||||
xdgDataHome: process.env.XDG_DATA_HOME ?? null,
|
||||
})),
|
||||
];
|
||||
const result = await buildDiagnosticsZip({
|
||||
context: {
|
||||
app: { name: "open-design", version: app.getVersion(), packaged: app.isPackaged },
|
||||
@@ -121,7 +188,7 @@ export async function exportDiagnosticsToFile(
|
||||
sourceTag: runtime.source,
|
||||
},
|
||||
},
|
||||
sources: buildSidecarLogSources(runtime),
|
||||
sources,
|
||||
redaction: { username: safeUsername() },
|
||||
crashReports: {
|
||||
// Restrict to Open Design's own process names. A generic "Electron"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { LogSource } from "./sources.js";
|
||||
|
||||
const DEFAULT_RUN_EVENT_TAIL_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_RUN_EVENT_LOGS = 20;
|
||||
|
||||
// CLI session logs can be large (e.g. Codex's `codex-tui.log` grows into the
|
||||
// hundreds of MB because it logs every TUI frame), so keep a generous-but-
|
||||
// bounded tail that still captures the final error frame.
|
||||
const DEFAULT_AGENT_LOG_TAIL_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_FILES_PER_AGENT = 3;
|
||||
|
||||
const SAFE_DIR_ENTRY = /^[A-Za-z0-9._-]+$/u;
|
||||
|
||||
async function statSafe(path: string): Promise<{ mtimeMs: number; isFile: boolean } | null> {
|
||||
try {
|
||||
const info = await stat(path);
|
||||
return { mtimeMs: info.mtimeMs, isFile: info.isFile() };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the most-recent per-run event logs (`<runsDir>/<runId>/events.jsonl`).
|
||||
*
|
||||
* These are the daemon's capture of each agent CLI's own stdout/stderr/stream
|
||||
* for a run — the single richest, agent-agnostic diagnostic signal for "the
|
||||
* run failed / produced nothing". Shared so both the daemon HTTP export and the
|
||||
* desktop IPC export bundle the same data.
|
||||
*/
|
||||
export async function buildRunEventLogSources(
|
||||
runsDir: string | null | undefined,
|
||||
options: { maxRuns?: number; tailBytes?: number } = {},
|
||||
): Promise<LogSource[]> {
|
||||
if (!runsDir) return [];
|
||||
const maxRuns = options.maxRuns ?? DEFAULT_MAX_RUN_EVENT_LOGS;
|
||||
const tailBytes = options.tailBytes ?? DEFAULT_RUN_EVENT_TAIL_BYTES;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(runsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: Array<{ runId: string; absolutePath: string; mtimeMs: number }> = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!SAFE_DIR_ENTRY.test(entry.name)) continue;
|
||||
const absolutePath = join(runsDir, entry.name, "events.jsonl");
|
||||
const info = await statSafe(absolutePath);
|
||||
if (!info || !info.isFile) continue;
|
||||
candidates.push({ runId: entry.name, absolutePath, mtimeMs: info.mtimeMs });
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates.slice(0, maxRuns).map(({ runId, absolutePath }) => ({
|
||||
name: `runs/${runId}/events.jsonl`,
|
||||
absolutePath,
|
||||
kind: "text",
|
||||
tailBytes,
|
||||
}));
|
||||
}
|
||||
|
||||
async function latestLogFilesIn(
|
||||
dir: string,
|
||||
max: number,
|
||||
): Promise<Array<{ name: string; absolutePath: string }>> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const candidates: Array<{ name: string; absolutePath: string; mtimeMs: number }> = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith(".log")) continue;
|
||||
if (!SAFE_DIR_ENTRY.test(name)) continue;
|
||||
const absolutePath = join(dir, name);
|
||||
const info = await statSafe(absolutePath);
|
||||
if (!info || !info.isFile) continue;
|
||||
candidates.push({ name, absolutePath, mtimeMs: info.mtimeMs });
|
||||
}
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates.slice(0, max).map(({ name, absolutePath }) => ({ name, absolutePath }));
|
||||
}
|
||||
|
||||
export interface AgentCliLogOptions {
|
||||
/** User home directory; CLI configs/logs live under here. */
|
||||
homeDir: string;
|
||||
/** Open Design data dir (OD_DATA_DIR); fallback location of the AMR OpenCode home. */
|
||||
dataDir?: string | null;
|
||||
/**
|
||||
* Effective AMR OpenCode home (the resolved `OPENCODE_TEST_HOME`). Callers
|
||||
* should pass the value a real AMR run would use so this honors a user
|
||||
* `agentCliEnv.amr.OPENCODE_TEST_HOME` override; when omitted we fall back to
|
||||
* the default `<dataDir>/amr/opencode-home`.
|
||||
*/
|
||||
amrOpenCodeHome?: string | null;
|
||||
/**
|
||||
* Effective Claude config home (`CLAUDE_CONFIG_DIR`). Honors a user
|
||||
* `agentCliEnv.claude.CLAUDE_CONFIG_DIR` override; falls back to `~/.claude`.
|
||||
*/
|
||||
claudeConfigDir?: string | null;
|
||||
/**
|
||||
* Effective Codex home (`CODEX_HOME`). Honors a user
|
||||
* `agentCliEnv.codex.CODEX_HOME` override; falls back to `~/.codex`.
|
||||
*/
|
||||
codexHome?: string | null;
|
||||
/** XDG_DATA_HOME, if set, used for OpenCode's data dir resolution. */
|
||||
xdgDataHome?: string | null;
|
||||
/** Max log files to include per agent (most-recent first). */
|
||||
maxFilesPerAgent?: number;
|
||||
/** Max bytes to read from each log file's tail. */
|
||||
tailBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the native on-disk logs of the coding-agent CLIs Open Design drives:
|
||||
* Claude Code, Codex, OpenCode, and AMR (vela's OpenCode runtime). These
|
||||
* complement the per-run `events.jsonl` capture with the CLI's own internal
|
||||
* diagnostics (provider errors, retries, auth state) that never reach the
|
||||
* daemon's stream.
|
||||
*
|
||||
* Deliberately excluded: secret-bearing files. We only sweep known *log*
|
||||
* directories (`*.log` files) — never `~/.amr/config.json`, `~/.codex/auth.json`,
|
||||
* or session transcripts — and the collected text still passes through the
|
||||
* bundle's redaction pass.
|
||||
*/
|
||||
export async function buildAgentCliLogSources(options: AgentCliLogOptions): Promise<LogSource[]> {
|
||||
const home = options.homeDir?.trim();
|
||||
if (!home) return [];
|
||||
const maxFiles = options.maxFilesPerAgent ?? DEFAULT_MAX_FILES_PER_AGENT;
|
||||
const tailBytes = options.tailBytes ?? DEFAULT_AGENT_LOG_TAIL_BYTES;
|
||||
|
||||
const xdg = options.xdgDataHome?.trim();
|
||||
const openCodeUserLogDir = xdg
|
||||
? join(xdg, "opencode", "log")
|
||||
: join(home, ".local", "share", "opencode", "log");
|
||||
|
||||
// Effective CLI homes honor the user's app-config overrides
|
||||
// (`CLAUDE_CONFIG_DIR` / `CODEX_HOME`), falling back to the defaults — the
|
||||
// same fix as AMR's `OPENCODE_TEST_HOME` so relocated homes are not missed.
|
||||
const claudeDir = options.claudeConfigDir?.trim() || join(home, ".claude");
|
||||
const codexHome = options.codexHome?.trim() || join(home, ".codex");
|
||||
|
||||
// Per-agent log directories. `*.log` only; secret files (auth.json,
|
||||
// config.json) live outside these dirs and are never swept.
|
||||
const agentDirs: Array<{ agent: string; dir: string }> = [
|
||||
// Claude Code operational logs (bash-commands / cost-tracker / daemon).
|
||||
{ agent: "claude", dir: claudeDir },
|
||||
// Codex tracing logs (codex-tui.log can be very large — tail-bounded).
|
||||
{ agent: "codex", dir: join(codexHome, "log") },
|
||||
// OpenCode session logs (provider errors live here in headless mode).
|
||||
{ agent: "opencode", dir: openCodeUserLogDir },
|
||||
];
|
||||
|
||||
// AMR drives a private OpenCode under OPENCODE_TEST_HOME; its session logs
|
||||
// land under that home's XDG data dir, where AMR run provider failures are
|
||||
// recorded. Prefer the effective home a real run resolves (honoring a user
|
||||
// `agentCliEnv.amr.OPENCODE_TEST_HOME` override) and fall back to the default
|
||||
// `<dataDir>/amr/opencode-home` so the bundle does not silently drop the very
|
||||
// AMR logs this is meant to surface.
|
||||
const amrHome = options.amrOpenCodeHome?.trim();
|
||||
const dataDir = options.dataDir?.trim();
|
||||
const amrOpenCodeHome = amrHome || (dataDir ? join(dataDir, "amr", "opencode-home") : "");
|
||||
if (amrOpenCodeHome) {
|
||||
agentDirs.push({
|
||||
agent: "amr",
|
||||
dir: join(amrOpenCodeHome, ".local", "share", "opencode", "log"),
|
||||
});
|
||||
}
|
||||
|
||||
const sources: LogSource[] = [];
|
||||
for (const { agent, dir } of agentDirs) {
|
||||
const files = await latestLogFilesIn(dir, maxFiles);
|
||||
for (const file of files) {
|
||||
sources.push({
|
||||
name: `agent-cli-logs/${agent}/${file.name}`,
|
||||
absolutePath: file.absolutePath,
|
||||
kind: "text",
|
||||
tailBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -36,3 +36,9 @@ export {
|
||||
type DiagnosticsExportInput,
|
||||
type DiagnosticsExportResult,
|
||||
} from "./zip.js";
|
||||
|
||||
export {
|
||||
buildRunEventLogSources,
|
||||
buildAgentCliLogSources,
|
||||
type AgentCliLogOptions,
|
||||
} from "./agent-logs.js";
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAgentCliLogSources, buildRunEventLogSources } from "../src/agent-logs.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "diagnostics-agent-logs-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function touch(path: string, content = "x"): Promise<void> {
|
||||
await mkdir(join(path, ".."), { recursive: true });
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
|
||||
describe("buildRunEventLogSources", () => {
|
||||
it("returns [] when no runs dir", async () => {
|
||||
expect(await buildRunEventLogSources(null)).toEqual([]);
|
||||
expect(await buildRunEventLogSources(join(tempDir, "missing"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("collects the most-recent per-run events.jsonl, newest first", async () => {
|
||||
const runsDir = join(tempDir, "runs");
|
||||
await touch(join(runsDir, "run-a", "events.jsonl"), "a");
|
||||
await touch(join(runsDir, "run-b", "events.jsonl"), "b");
|
||||
// Directory without events.jsonl is skipped.
|
||||
await mkdir(join(runsDir, "run-empty"), { recursive: true });
|
||||
|
||||
const sources = await buildRunEventLogSources(runsDir, { maxRuns: 5 });
|
||||
const names = sources.map((s) => s.name).sort();
|
||||
expect(names).toEqual(["runs/run-a/events.jsonl", "runs/run-b/events.jsonl"]);
|
||||
for (const source of sources) {
|
||||
expect(source.kind).toBe("text");
|
||||
expect(source.tailBytes).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("caps the number of runs", async () => {
|
||||
const runsDir = join(tempDir, "runs");
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await touch(join(runsDir, `run-${i}`, "events.jsonl"), String(i));
|
||||
}
|
||||
const sources = await buildRunEventLogSources(runsDir, { maxRuns: 2 });
|
||||
expect(sources).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentCliLogSources", () => {
|
||||
it("collects claude / codex / opencode / amr log files under their known dirs", async () => {
|
||||
const home = join(tempDir, "home");
|
||||
const dataDir = join(tempDir, "data");
|
||||
await touch(join(home, ".claude", "daemon.log"));
|
||||
await touch(join(home, ".codex", "log", "codex-tui.log"));
|
||||
await touch(join(home, ".local", "share", "opencode", "log", "2026-01-01T00.log"));
|
||||
await touch(
|
||||
join(dataDir, "amr", "opencode-home", ".local", "share", "opencode", "log", "2026-01-02T00.log"),
|
||||
);
|
||||
// Secret-bearing files outside *.log dirs must NOT be swept.
|
||||
await touch(join(home, ".codex", "auth.json"), "secret");
|
||||
await touch(join(home, ".amr", "config.json"), "secret");
|
||||
|
||||
const sources = await buildAgentCliLogSources({ homeDir: home, dataDir });
|
||||
const names = sources.map((s) => s.name);
|
||||
|
||||
expect(names).toContain("agent-cli-logs/claude/daemon.log");
|
||||
expect(names).toContain("agent-cli-logs/codex/codex-tui.log");
|
||||
expect(names).toContain("agent-cli-logs/opencode/2026-01-01T00.log");
|
||||
expect(names).toContain("agent-cli-logs/amr/2026-01-02T00.log");
|
||||
// No secrets, and only *.log files.
|
||||
expect(names.some((n) => n.includes("auth.json"))).toBe(false);
|
||||
expect(names.some((n) => n.includes("config.json"))).toBe(false);
|
||||
for (const source of sources) {
|
||||
expect(source.name.endsWith(".log")).toBe(true);
|
||||
expect(source.tailBytes).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("honors an explicit amrOpenCodeHome override over the dataDir default", async () => {
|
||||
const home = join(tempDir, "home");
|
||||
const dataDir = join(tempDir, "data");
|
||||
const overrideHome = join(tempDir, "custom-amr-home");
|
||||
// Default location has a log, but the override points elsewhere — only the
|
||||
// override's logs should be swept (mirrors a user OPENCODE_TEST_HOME).
|
||||
await touch(
|
||||
join(dataDir, "amr", "opencode-home", ".local", "share", "opencode", "log", "default.log"),
|
||||
);
|
||||
await touch(
|
||||
join(overrideHome, ".local", "share", "opencode", "log", "override.log"),
|
||||
);
|
||||
|
||||
const sources = await buildAgentCliLogSources({ homeDir: home, dataDir, amrOpenCodeHome: overrideHome });
|
||||
const amrNames = sources.filter((s) => s.name.startsWith("agent-cli-logs/amr/")).map((s) => s.name);
|
||||
expect(amrNames).toContain("agent-cli-logs/amr/override.log");
|
||||
expect(amrNames).not.toContain("agent-cli-logs/amr/default.log");
|
||||
});
|
||||
|
||||
it("honors claudeConfigDir / codexHome overrides over the home defaults", async () => {
|
||||
const home = join(tempDir, "home");
|
||||
const claudeDir = join(tempDir, "custom-claude");
|
||||
const codexHome = join(tempDir, "custom-codex");
|
||||
// Default homes have logs that must be ignored when overrides are set.
|
||||
await touch(join(home, ".claude", "daemon.log"));
|
||||
await touch(join(home, ".codex", "log", "codex-tui.log"));
|
||||
await touch(join(claudeDir, "cost-tracker.log"));
|
||||
await touch(join(codexHome, "log", "session.log"));
|
||||
|
||||
const sources = await buildAgentCliLogSources({ homeDir: home, claudeConfigDir: claudeDir, codexHome });
|
||||
const names = sources.map((s) => s.name);
|
||||
expect(names).toContain("agent-cli-logs/claude/cost-tracker.log");
|
||||
expect(names).toContain("agent-cli-logs/codex/session.log");
|
||||
expect(names).not.toContain("agent-cli-logs/claude/daemon.log");
|
||||
expect(names).not.toContain("agent-cli-logs/codex/codex-tui.log");
|
||||
});
|
||||
|
||||
it("honors XDG_DATA_HOME for the opencode user log dir", async () => {
|
||||
const home = join(tempDir, "home");
|
||||
const xdg = join(tempDir, "xdg");
|
||||
await touch(join(xdg, "opencode", "log", "session.log"));
|
||||
|
||||
const sources = await buildAgentCliLogSources({ homeDir: home, xdgDataHome: xdg });
|
||||
expect(sources.map((s) => s.name)).toContain("agent-cli-logs/opencode/session.log");
|
||||
});
|
||||
|
||||
it("returns [] when homeDir is empty", async () => {
|
||||
expect(await buildAgentCliLogSources({ homeDir: "" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user