Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* that decides which sessions it can — a kept session is owned by the run that
* made it, and every commission is a new run — and nothing about the session it
* named. It also says the ways a kept session ends — once it has been idle for
* an hour, once the executor has restarted since it was made, or once the grant
* an hour, once the executor has restarted since it was made, once a turn
* naming a new base has failed to prepare its checkout again, or once the grant
* of its connected folder is withdrawn — because a session this run made and
* lost one of those ways reaches the same refusal as another run's. Another
* run's session, this run's idled-out one and one that never existed all get
Expand Down Expand Up @@ -140,6 +141,13 @@ describe('a commission that names a session it cannot continue', () => {
expect(message).toContain('once this executor has restarted since it was made');
});

it('is told that a kept session ends when its checkout cannot be prepared again', async () => {
const { message } = await commission(OTHER_RUNS);
expect(message).toContain(
"once a turn naming a new `base` has failed to prepare the session's checkout again",
);
});

it("is told that a kept session ends when its folder's grant is withdrawn", async () => {
const { message } = await commission(OTHER_RUNS);
expect(message).toContain('or once the grant of its connected folder is withdrawn.');
Expand Down
36 changes: 27 additions & 9 deletions apps/aflow-executor-host/src/__tests__/harnessResult.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
compileResultValidator,
composeHarnessTask,
createHostHarnessHandler,
hasRepositoryRules,
repositoryRulesFiles,
readHarnessResult,
resultInstruction,
retryInstruction,
Expand Down Expand Up @@ -117,26 +117,44 @@ describe("the task carries the repository's own rules", () => {
await rm(checkout, { recursive: true, force: true });
});

it('names CLAUDE.md before the task when the checkout holds one', async () => {
it('names CLAUDE.md at the root of the checkout before the task when it holds one', async () => {
await writeFile(join(checkout, 'CLAUDE.md'), '# Rules\n', 'utf8');
expect(await hasRepositoryRules(checkout)).toBe(true);
const text = composeHarnessTask('Review the range.', { range: 'main..HEAD' }, true);
expect(text.startsWith("The repository's own instructions are in `CLAUDE.md`")).toBe(true);
expect(text).toContain('treat them as binding');
const files = await repositoryRulesFiles({ path: checkout, workDir: checkout });
expect(files).toEqual([{ path: 'CLAUDE.md', where: 'at the root of this checkout' }]);
const text = composeHarnessTask('Review the range.', { range: 'main..HEAD' }, files);
expect(
text.startsWith(
"The repository's own instructions are in `CLAUDE.md` at the root of this checkout; " +
'read them first and treat them as binding for every change.',
),
).toBe(true);
expect(text).toContain('Review the range.');
expect(text).toContain('"range":"main..HEAD"');
});

it('says nothing about it when the checkout has none', async () => {
expect(await hasRepositoryRules(checkout)).toBe(false);
expect(composeHarnessTask('Review the range.', undefined, false)).toBe('Review the range.');
expect(await repositoryRulesFiles({ path: checkout, workDir: checkout })).toEqual([]);
expect(composeHarnessTask('Review the range.', undefined, [])).toBe('Review the range.');
});

it('asks the checkout rather than a directory of the same name', async () => {
// A directory called CLAUDE.md is not a file the harness can read, and a
// task telling it to read one would send it after nothing.
await mkdir(join(checkout, 'CLAUDE.md'), { recursive: true });
expect(await hasRepositoryRules(checkout)).toBe(false);
expect(await repositoryRulesFiles({ path: checkout, workDir: checkout })).toEqual([]);
});

it("names the folder's own and its repository's, each where it is, for a folder inside one", async () => {
const workDir = join(checkout, 'packages', 'app');
await mkdir(workDir, { recursive: true });
await writeFile(join(checkout, 'CLAUDE.md'), '# Repository rules\n', 'utf8');
await writeFile(join(workDir, 'CLAUDE.md'), '# Package rules\n', 'utf8');
const files = await repositoryRulesFiles({ path: checkout, workDir });
expect(composeHarnessTask('Fix it.', undefined, files)).toBe(
"The repository's own instructions are in `CLAUDE.md` in this folder and " +
'`../../CLAUDE.md` at the top level of the repository this folder is in; read them ' +
'first and treat them as binding for every change.\n\nFix it.',
);
});
});

Expand Down
145 changes: 145 additions & 0 deletions apps/aflow-executor-host/src/__tests__/packageRules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Contract: a folder connected as a package of a repository is briefed with the
* repository's rules as its top level is. The brief names the `CLAUDE.md` at the
* top of the clone by its path from the folder the harness works in, and the
* folder's own beside it when it has one.
*
* The spawn is stood in for and records the task it is handed.
*/
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { SandboxedRunInput, SandboxedRunResult } from '../sandboxedRun.js';

const run = promisify(execFile);

const sandboxed: SandboxedRunInput[] = [];

vi.mock('../sandboxedRun.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../sandboxedRun.js')>();
return {
...actual,
sandboxReadiness: () => ({ ready: true, missing: [] }),
runSandboxed: (input: SandboxedRunInput): Promise<SandboxedRunResult> => {
sandboxed.push(input);
return Promise.resolve({
processId: 'hr_rules',
exitCode: 0,
signal: null,
timedOut: false,
durationMs: 0,
stdout: 'done',
stderr: '',
truncated: false,
});
},
};
});

const { createHostHarnessHandler } = await import('../handlers/harnessHandlers.js');

const TOP_RULES =
"The repository's own instructions are in `../../CLAUDE.md` at the top level of the " +
'repository this folder is in; read them first and treat them as binding for every change.';

let base: string;
let top: string;
let folder: string;

async function git(...args: string[]): Promise<void> {
await run('git', ['-C', top, ...args]);
}

async function commit(files: Record<string, string>): Promise<void> {
for (const [path, text] of Object.entries(files)) {
await mkdir(join(top, path, '..'), { recursive: true });
await writeFile(join(top, path), text);
}
await git('add', '-A');
await git('commit', '-qm', 'the base commit');
}

/** The task the harness was handed, from a commission on the connected package. */
async function briefOf(): Promise<string> {
const policyPath = join(base, 'host-policy.json');
await writeFile(
policyPath,
JSON.stringify({
version: 1,
bindings: [
{
id: 'hb_package',
root: folder,
mode: 'readwrite',
allowsExecution: true,
singleFile: false,
spaceId: 'space-a',
},
],
harnesses: [{ id: 'agent', executable: '/bin/sh', args: ['-c', 'true'] }],
}),
);
const result = await createHostHarnessHandler(policyPath).execute({
operationId: 'host.harness.run',
spaceId: 'space-a',
runId: 'run-package-rules',
job: { inputRef: 'inline:x' },
signal: new AbortController().signal,
log: { error: () => undefined, warn: () => undefined, info: () => undefined },
readPayload: () =>
Promise.resolve({ bindingId: 'hb_package', task: 'Fix it.', timeoutMs: 60_000 }),
emitLiveDelta: () => Promise.resolve(),
writePayload: (kind: string) => Promise.resolve(`inline:${kind}`),
} as never);
expect(result.status).toBe('SUCCEEDED');
expect(sandboxed).toHaveLength(1);
const argv = sandboxed[0]?.argv ?? [];
const task = argv.find((arg) => arg.endsWith('Fix it.'));
expect(task, argv.join(' ')).toBeDefined();
return task ?? '';
}

beforeEach(async () => {
sandboxed.length = 0;
base = await realpath(await mkdtemp(join(tmpdir(), 'aflow-package-rules-')));
top = join(base, 'mono');
folder = join(top, 'packages', 'app');
await mkdir(folder, { recursive: true });
await git('init', '-q', '--initial-branch=main');
await git('config', 'user.email', 'test@example.com');
await git('config', 'user.name', 'Test');
});

afterEach(async () => {
await rm(base, { recursive: true, force: true });
});

describe('a package of a repository with a root CLAUDE.md, connected', () => {
it("is briefed with the repository's rules, named by their path from the package", async () => {
await commit({ 'CLAUDE.md': '# Rules\n', 'packages/app/index.js': '1;\n' });
expect(await briefOf()).toBe(`${TOP_RULES}\n\nFix it.`);
});

it("is briefed with its own rules and the repository's, each where it is", async () => {
await commit({
'CLAUDE.md': '# Rules\n',
'packages/app/CLAUDE.md': '# Package rules\n',
'packages/app/index.js': '1;\n',
});
expect(await briefOf()).toBe(
"The repository's own instructions are in `CLAUDE.md` in this folder and " +
'`../../CLAUDE.md` at the top level of the repository this folder is in; read them ' +
'first and treat them as binding for every change.\n\nFix it.',
);
});

it('is briefed with none when the repository holds none', async () => {
await commit({ 'packages/app/index.js': '1;\n' });
expect(await briefOf()).toBe('Fix it.');
});
});
Loading
Loading