mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge #778 fix(cli): a running server notices a CLI the installer just added
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
98c4a3d386
10 files changed
+198
-15
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
|
||||
import {
|
||||
CLI_INSTALLED_MARKER,
|
||||
cliResolveRetryDelayMs,
|
||||
createCliExecutableResolver,
|
||||
createProductionCliResolverHost,
|
||||
@@ -130,6 +131,107 @@ describe('createCliExecutableResolver', () => {
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('the installer says a CLI was installed', () => {
|
||||
// install.sh touches a marker when it installs a CLI, and the stamp is that file's
|
||||
// mtime. A running server must see the new CLI at the next request, because Run reads
|
||||
// this same answer: "Run fails, add the agent, Run again" must not fail twice.
|
||||
function waiting() {
|
||||
const clock = { now: 0, stamp: 1000 };
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValue(null);
|
||||
const onPath = vi.fn<() => string | null>().mockReturnValue(null);
|
||||
const installStamp = vi.fn(() => clock.stamp);
|
||||
const h = host({
|
||||
findInLoginShell,
|
||||
findOnProcessPath: onPath,
|
||||
exists: vi.fn((path) => path === '/new/bin/codex'),
|
||||
installStamp,
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => clock.now }, h);
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
return { clock, findInLoginShell, onPath, installStamp, resolver };
|
||||
}
|
||||
|
||||
it('ends the backoff at once when the stamp changes', () => {
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
findInLoginShell.mockReturnValue('/new/bin/codex');
|
||||
clock.stamp = 2000;
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
});
|
||||
|
||||
it('keeps the backoff, with nothing spawned, while the stamp stands still', () => {
|
||||
const { clock, findInLoginShell, onPath, resolver } = waiting();
|
||||
clock.now = cliResolveRetryDelayMs(1) - 1;
|
||||
for (let i = 0; i < 50; i++) expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||
expect(onPath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a marker older than the server is not news: it does not cost a second chain', () => {
|
||||
// A stamp present from the first look on must not read as a change at the second.
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('one change buys one look: a CLI that is still missing goes back to waiting', () => {
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
clock.stamp = 2000;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never looks at the marker once a CLI is resolved', () => {
|
||||
const { clock, findInLoginShell, installStamp, resolver } = waiting();
|
||||
findInLoginShell.mockReturnValue('/new/bin/codex');
|
||||
clock.stamp = 2000;
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
installStamp.mockClear();
|
||||
for (let i = 0; i < 10; i++) resolver.resolve();
|
||||
expect(installStamp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a stamp going backwards is a change too, or a restored data folder would hide one', () => {
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
clock.stamp = 500;
|
||||
resolver.resolve();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the real marker file, and treats a missing one as no news', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
|
||||
const marker = join(dir, CLI_INSTALLED_MARKER);
|
||||
try {
|
||||
const h = createProductionCliResolverHost({ allowRealIoUnderVitest: true, installMarkerPath: marker });
|
||||
expect(h.installStamp?.()).toBe(0);
|
||||
writeFileSync(marker, '');
|
||||
utimesSync(marker, 1000, 1000);
|
||||
const first = h.installStamp?.();
|
||||
expect(first).toBeGreaterThan(0);
|
||||
utimesSync(marker, 2000, 2000);
|
||||
expect(h.installStamp?.()).not.toBe(first);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads no marker under vitest, even one that is there', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
|
||||
const marker = join(dir, CLI_INSTALLED_MARKER);
|
||||
try {
|
||||
writeFileSync(marker, '');
|
||||
expect(createProductionCliResolverHost({ installMarkerPath: marker }).installStamp?.()).toBe(0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
|
||||
|
||||
@@ -552,8 +552,7 @@ describe('adding a coding agent to a Mac that has Codeman', () => {
|
||||
const t = installing();
|
||||
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
|
||||
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
|
||||
expect(t.$('wiz-lead').textContent).toContain('within a few minutes');
|
||||
expect(t.$('wiz-lead').textContent).toContain('reload');
|
||||
expect(t.$('wiz-lead').textContent).toContain('reload it, or give it a few minutes');
|
||||
expect(t.rail()).toEqual([['Installing Codex', 'done']]);
|
||||
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
|
||||
expect(t.sentOf('connect')).toHaveLength(0);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import { CLI_INSTALLED_MARKER } from '../src/utils/cli-executable-resolver.js';
|
||||
import { ARCHIVE_INSTALL_MARKER } from '../src/web/self-update.js';
|
||||
|
||||
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
|
||||
@@ -583,6 +584,40 @@ describe('install.sh agent, one AI CLI and nothing else', () => {
|
||||
return { status: result.status, stdout, stderr, ran: [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]) };
|
||||
}
|
||||
|
||||
/** The same, in a home folder of its own, reporting whether the marker was written. */
|
||||
function agentInHome(args: string[], env: Record<string, string> = {}) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-marker-'));
|
||||
try {
|
||||
mkdirSync(join(home, '.codeman'));
|
||||
const run = agent(args, { HOME: home, ...env });
|
||||
return { ...run, marked: existsSync(join(home, '.codeman', CLI_INSTALLED_MARKER)) };
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('tells a running server when the agent landed, and only then', () => {
|
||||
expect(agentInHome(['--ai-cli', 'codex', '--no-admin'], { LEAVES: 'codex' }).marked).toBe(true);
|
||||
expect(agentInHome(['--ai-cli', 'codex', '--no-admin']).marked).toBe(false);
|
||||
// Installed by hand a minute ago, and the server may still remember it as missing.
|
||||
expect(agentInHome(['--ai-cli', 'codex', '--no-admin'], { FOUND: 'codex' }).marked).toBe(true);
|
||||
});
|
||||
|
||||
it('a home folder with no Codeman in it is no failure', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-marker-'));
|
||||
try {
|
||||
const run = agent(['--ai-cli', 'codex', '--no-admin'], { HOME: home, LEAVES: 'codex' });
|
||||
expect(run.status, run.stderr).toBe(0);
|
||||
expect(readdirSync(home)).toEqual([]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the file the server reads', () => {
|
||||
expect(SOURCE).toContain(`"$HOME/.codeman/${CLI_INSTALLED_MARKER}"`);
|
||||
});
|
||||
|
||||
it('installs the one it was told to, and says done', () => {
|
||||
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { LEAVES: 'codex' });
|
||||
expect(run.status, run.stderr).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user