Files
Codeman/test/workspace-hooks-unreachable-mount.test.ts
T
Aamer Akhter d1bfbb4fcf fix(cases): tell an unreachable path from an absent one, scope the stall cap
The bounded path probe answered "absent" both when a path did not exist and
when it simply did not answer, so a stalled linked case 404'd and the Run
button scaffolded a stray local case over it, and two stalled paths anywhere
made every unrelated path read as absent (hooks skipped, statusLine
overridden, the clone warning lost).

- probePath()/probePathKind() are tri-state: present (or directory/file),
  absent (ENOENT/ENOTDIR only) and unknown (timeout, other errors, refusal).
  boundedPathExists() stays as the display-only boolean.
- A stalled path takes only its own mount out of probing (deepest mount
  point from /proc/self/mounts, never /; just the path itself when there is
  no mount table). Unrelated paths keep probing. The process-wide cap is a
  backstop that answers unknown, and a single-path user request can probe
  past it ({ pastCap: true }), still bounded and still recorded as stalled.
  One console.warn when a path first stalls and one when the cap engages.
- GET /api/cases/:name keeps NOT_FOUND for definite absence only. An
  unreachable linked case answers with its registered path and
  unreachable: true; a local one answers OPERATION_FAILED. runClaude and
  runShell create a case only on errorCode NOT_FOUND. The case list keeps an
  unreachable linked case, marked unreachable, instead of dropping it, and
  fix-plan reports an unreadable plan as an error, not "no plan".
- applyWorkspaceHooks and the statusLine helpers skip only a workspace that
  is absent or on the stalled mount; a capacity refusal no longer stops
  hooks being installed elsewhere, and an unreadable settings file never
  lets the exporter override a user's own statusLine.
- The clone flow's repo-settings warning is back on its synchronous check,
  and stripCaseEnvKeys uses pathExistsForWrite.
- POST /api/sessions (workingDir) and POST /api/quick-start (case folder)
  probe with the bounded probe instead of statSync/existsSync. Missing and
  non-directory keep INVALID_INPUT; unknown is OPERATION_FAILED, and
  quick-start never scaffolds over a folder that did not answer.
- PATH_PROBE_TIMEOUT_MS and MAX_STALLED_PATH_PROBES move to
  src/config/path-probe.ts, overridable via CODEMAN_PATH_PROBE_TIMEOUT_MS
  (default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3), and are
  documented in the Settings Reference.
- The probe is exported from the utils barrel and imported from there.
2026-10-04 20:30:40 -04:00

146 lines
5.9 KiB
TypeScript

/**
* @fileoverview How the workspace hook and statusLine helpers in hooks-config.ts
* read an "unknown" answer from the bounded path probe. A dead network mount
* elsewhere on the machine (enough of them to engage the probe's stall cap) must
* not stop Codeman's hooks from being installed in a healthy workspace, and must
* not let the plan-usage exporter be injected over a user's own statusLine. A
* workspace that IS on the dead mount is skipped without hanging the caller.
*
* Real temp directories; only `stat()` of the chosen dead paths is made to hang.
* Port: none.
*/
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const probe = vi.hoisted(() => {
// Short probe timeout so the stalls below cost ~100 ms each, read at import.
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
return { dead: new Set<string>(), releases: [] as Array<() => void> };
});
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
const stat = ((path: string, ...rest: unknown[]) => {
for (const dead of probe.dead) {
if (String(path) === dead || String(path).startsWith(dead + '/')) {
return new Promise((resolve, reject) => {
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
void resolve;
});
}
}
return (actual.stat as (...a: unknown[]) => unknown)(path, ...rest);
}) as typeof actual.stat;
return { ...actual, stat, default: { ...actual, stat } };
});
import { applyWorkspaceHooks, resolveStatusLineCliCommand, stripCaseEnvKeys } from '../src/hooks-config.js';
import { probePath } from '../src/utils/index.js';
import { MAX_STALLED_PATH_PROBES } from '../src/config/path-probe.js';
const root = mkdtempSync(join(tmpdir(), 'codeman-unreachable-mount-'));
/** Stall `count` paths on unrelated "mounts" until afterEach releases them. */
async function stallUnrelatedMounts(count: number): Promise<void> {
const paths = Array.from({ length: count }, (_, i) => `/mnt/dead-nas-${i}/project`);
paths.forEach((p) => probe.dead.add(p));
expect(await Promise.all(paths.map((p) => probePath(p)))).toEqual(paths.map(() => 'unknown'));
}
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(async () => {
probe.dead.clear();
probe.releases.splice(0).forEach((release) => release());
await new Promise((r) => setTimeout(r, 0));
warn.mockRestore();
});
afterAll(() => {
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
});
describe('workspace helpers while other mounts are unreachable', () => {
it('installs hooks in a healthy workspace while the stall cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'healthy-a');
mkdirSync(workspace);
await applyWorkspaceHooks(workspace, true);
const settings = join(workspace, '.claude', 'settings.local.json');
expect(existsSync(settings)).toBe(true);
expect(readFileSync(settings, 'utf-8')).toContain('/api/hook-event');
});
it('installs hooks in a healthy workspace while two unrelated paths are stalled', async () => {
await stallUnrelatedMounts(2);
const workspace = join(root, 'healthy-b');
mkdirSync(workspace);
await applyWorkspaceHooks(workspace, true);
expect(existsSync(join(workspace, '.claude', 'settings.local.json'))).toBe(true);
});
it('skips, without hanging, a workspace that sits on the dead mount', async () => {
const workspace = '/mnt/dead-nas-x/project';
probe.dead.add('/mnt/dead-nas-x');
expect(await probePath(workspace)).toBe('unknown');
const started = Date.now();
await applyWorkspaceHooks(join('/mnt/dead-nas-x', 'project'), true);
expect(Date.now() - started).toBeLessThan(1_000);
expect(
warn.mock.calls.some(
(c: unknown[]) => /hooks/i.test(String(c[0])) && String(c[0]).includes('/mnt/dead-nas-x/project')
)
).toBe(true);
});
it('removes a superseded env key from a healthy workspace while the stall cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'strip-env');
mkdirSync(join(workspace, '.claude'), { recursive: true });
const settings = join(workspace, '.claude', 'settings.local.json');
writeFileSync(settings, JSON.stringify({ env: { CLAUDE_CODE_STALE: '1', USER_KEEP: '2' } }));
await stripCaseEnvKeys(workspace, ['CLAUDE_CODE_STALE']);
expect(JSON.parse(readFileSync(settings, 'utf-8')).env).toEqual({ USER_KEEP: '2' });
});
it("never injects the exporter over a user's own statusLine while the cap is engaged", async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'own-statusline');
mkdirSync(join(workspace, '.claude'), { recursive: true });
writeFileSync(
join(workspace, '.claude', 'settings.local.json'),
JSON.stringify({ statusLine: { type: 'command', command: 'my-own-statusline' } })
);
expect(await resolveStatusLineCliCommand(workspace, true)).toBeUndefined();
});
it('still injects the exporter in a healthy workspace without one while the cap is engaged', async () => {
await stallUnrelatedMounts(MAX_STALLED_PATH_PROBES);
const workspace = join(root, 'no-statusline');
mkdirSync(workspace);
expect(await resolveStatusLineCliCommand(workspace, true)).toMatch(/statusline-exporter\.sh$/);
});
it('does not inject the exporter into a workspace on the dead mount', async () => {
probe.dead.add('/mnt/dead-nas-y');
expect(await probePath('/mnt/dead-nas-y/project')).toBe('unknown');
expect(await resolveStatusLineCliCommand('/mnt/dead-nas-y/project', true)).toBeUndefined();
});
});