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
5 changes: 5 additions & 0 deletions apps/server/src/j5/test-support/liveTestHome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ it.live(
const manifest = yield* build;

assert.equal(manifest.home, NodePath.join(root, "home"));
// Each persona's second route is recorded too: a launch can resolve to it.
assert.deepEqual(manifest.personas, [
{ id: "haiku-helper", model: "claude-haiku-4-5", fallbackModel: "gpt-6-luna" },
{ id: "luna-helper", model: "gpt-6-luna", fallbackModel: "claude-haiku-4-5" },
]);
assert.deepEqual(
JSON.parse(NodeFS.readFileSync(NodePath.join(root, "manifest.json"), "utf8")),
manifest,
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/j5/test-support/liveTestHome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ export interface LiveTestHomeManifest {
readonly testRepoOrigin: string;
readonly project: { readonly id: string; readonly title: string };
readonly defaultModel: string;
readonly personas: ReadonlyArray<{ readonly id: string; readonly model: string }>;
/** `fallbackModel` is the second route, used when the first is unavailable or the launcher prefers its own provider. */
readonly personas: ReadonlyArray<{
readonly id: string;
readonly model: string;
readonly fallbackModel: string;
}>;
readonly playbooks: ReadonlyArray<string>;
readonly holdScript: string;
readonly holdReleaseFile: string;
Expand Down Expand Up @@ -374,7 +379,11 @@ export const buildLiveTestHome = Effect.fn("buildLiveTestHome")(function* (input
testRepoOrigin,
project: { id: projectId, title: LIVE_TEST_PROJECT_TITLE },
defaultModel: LIVE_TEST_DEFAULT_MODEL.model,
personas: LIVE_TEST_PERSONAS.map(({ id, routes }) => ({ id, model: routes[0].model })),
personas: LIVE_TEST_PERSONAS.map(({ id, routes }) => ({
id,
model: routes[0].model,
fallbackModel: routes[1].model,
})),
playbooks: LIVE_TEST_PLAYBOOKS,
holdScript: LIVE_TEST_HOLD_SCRIPT,
holdReleaseFile: NodePath.join(testRepo, LIVE_TEST_HOLD_RELEASE_FILE),
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/j5/test-support/liveTestRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,14 @@ export function createLiveTestRepo(input: {
git(input.repoDir, "remote", "add", "origin", input.originDir);
git(input.repoDir, "push", "--set-upstream", "origin", "main");
}

// Run directly, it makes just the repository, for a home that has no project yet:
// node liveTestRepo.ts <repo directory> <origin directory>
if (import.meta.main) {
const [repoDir, originDir] = process.argv.slice(2);
if (repoDir === undefined || originDir === undefined || process.argv.length !== 4) {
process.stderr.write("Usage: liveTestRepo.ts <repo directory> <origin directory>\n");
process.exit(64);
}
createLiveTestRepo({ repoDir, originDir });
}
55 changes: 49 additions & 6 deletions scripts/j5/setup-agent-live-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
classifyServer,
LiveTestRefusal,
liveTestEnvironment,
type LiveTestMode,
modeRefusal,
parseCommand,
readServerLog,
resolveLiveTestPaths,
Expand Down Expand Up @@ -301,12 +303,53 @@ describe("classifyServer", () => {
});

describe("parseCommand", () => {
it("defaults to start and refuses anything it does not know", () => {
assert.equal(parseCommand([]), "start");
assert.equal(parseCommand(["restart"]), "restart");
assert.equal(parseCommand(["rebuild"]), "rebuild");
assert.throws(() => parseCommand(["--fresh"]), LiveTestRefusal);
assert.throws(() => parseCommand(["stop", "now"]), LiveTestRefusal);
it("defaults to a start in default mode", () => {
assert.deepEqual(parseCommand([]), { command: "start", mode: "default" });
assert.deepEqual(parseCommand(["restart"]), { command: "restart", mode: "default" });
});

it("takes a mode flag on a start and on rebuild, in either position", () => {
assert.deepEqual(parseCommand(["--fresh"]), { command: "start", mode: "fresh" });
assert.deepEqual(parseCommand(["rebuild", "--fresh"]), { command: "rebuild", mode: "fresh" });
assert.deepEqual(parseCommand(["--fresh", "rebuild"]), { command: "rebuild", mode: "fresh" });
});

it("refuses unknown words and flags, and a mode flag where none applies", () => {
for (const args of [
["bogus"],
["stop", "now"],
["--peer"],
["--fresh", "--fresh"],
// These act on whatever is built; a flag would suggest they could change it.
["status", "--fresh"],
["restart", "--fresh"],
["stop", "--fresh"],
]) {
assert.throws(() => parseCommand(args), LiveTestRefusal);
}
});
});

describe("modeRefusal", () => {
const commands = {
startCommand: (mode: LiveTestMode) => `start:${mode}`,
rebuildCommand: (mode: LiveTestMode) => `rebuild:${mode}`,
};

it("lets a start use a home built in the mode it asks for", () => {
assert.isUndefined(modeRefusal({ requested: "fresh", built: "fresh", ...commands }));
assert.isUndefined(modeRefusal({ requested: "default", built: "default", ...commands }));
});

it("refuses the other mode and names both ways forward", () => {
// A bare start asks for the test project; an empty home must not answer it.
const bare = modeRefusal({ requested: "default", built: "fresh", ...commands }) ?? "";
assert.include(bare, "built in fresh mode, not default mode");
assert.include(bare, "start:fresh");
assert.include(bare, "rebuild:default");
const fresh = modeRefusal({ requested: "fresh", built: "default", ...commands }) ?? "";
assert.include(fresh, "start:default");
assert.include(fresh, "rebuild:fresh");
});
});

Expand Down
155 changes: 129 additions & 26 deletions scripts/j5/setup-agent-live-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
* node scripts/j5/setup-agent-live-test.ts stop stop the server
* node scripts/j5/setup-agent-live-test.ts rebuild stop, delete the test home and repository, build and start again
*
* A start and `rebuild` take a mode flag. With none, the home holds a test
* project and pre-made threads. `--fresh` gives an empty home, so the first-run
* wizard shows. A worktree has one live-test home at a time and its mode is
* fixed when it is built: `rebuild` with another flag switches.
*
* The test repository, the J5 home that holds only that project, the server's
* log and the record of its process group live in this worktree's gitignored
* `.j5code/live-test`. (The dev server and the provider CLIs still write their
Expand Down Expand Up @@ -41,14 +46,27 @@ const RECOVERY_LOG_LINE = "V2 orchestration recovery completed";
/** A reason this script will not act, printed as-is. */
export class LiveTestRefusal extends Error {}

/** What the generator records about the build. Paths are never read from it: see LiveTestPaths. */
interface Manifest {
/** How the home is built: with the test project and pre-made threads, or empty. */
const MODE_FLAGS = { default: "", fresh: "--fresh" } as const;
export type LiveTestMode = keyof typeof MODE_FLAGS;

/** What the generator records about a default build. Paths are never read from it: see LiveTestPaths. */
interface DefaultManifest {
/** Absent in the generator's file, which only ever describes a default build. */
readonly mode?: "default";
readonly project: { readonly title: string };
readonly defaultModel: string;
readonly personas: ReadonlyArray<{ readonly id: string; readonly model: string }>;
readonly personas: ReadonlyArray<{
readonly id: string;
readonly model: string;
/** The second route. Absent in a home built before the generator recorded it. */
readonly fallbackModel?: string;
}>;
readonly playbooks: ReadonlyArray<string>;
readonly holdScript: string;
}
/** Marks a finished build and records its mode. */
type Manifest = DefaultManifest | { readonly mode: "fresh" };

/** Every path the script acts on, derived from the worktree and checked to stay inside it. */
export interface LiveTestPaths {
Expand Down Expand Up @@ -386,23 +404,40 @@ export function formatInstructions(input: {
The pairing URL works once. \`status\` prints a fresh one, and so does:
${pairCommand}

Project to use: "${manifest.project.title}", a small TODO app (\`pnpm test\`)
${
manifest.mode === "fresh"
? `Mode: fresh. The home is empty and no default model is set. The first-run
wizard shows only in a browser profile that has not completed it: pair in an
incognito tab.

Project to add when the wizard or a test asks for one: a small TODO app (\`pnpm test\`)
${paths.testRepo}
Add only this directory. Do not add a real repository: that is how a test
reaches one.`
: `Project to use: "${manifest.project.title}", a small TODO app (\`pnpm test\`)
${paths.testRepo}
It is the only project in this home. Do not add another project: that is how
a test reaches a real repository.

New threads default to ${manifest.defaultModel}.
Personas: ${manifest.personas.map((persona) => `${persona.id} (${persona.model})`).join(", ")}
Personas: ${manifest.personas
.map(
(persona) =>
`${persona.id} (${persona.model}${persona.fallbackModel === undefined ? "" : `, or ${persona.fallbackModel} as its second route`})`,
)
.join("; ")}
Playbooks: ${manifest.playbooks.join(", ")}

Hold a turn open by asking the agent to run: ${manifest.holdScript}
Release it with: touch ${NodePath.join(paths.testRepo, HOLD_RELEASE_FILE)}
Release it with: touch ${NodePath.join(paths.testRepo, HOLD_RELEASE_FILE)}`
}

Commands:
${selfCommand(paths, "status")} say whether it is running
${selfCommand(paths, "restart")} stop, then start on the same home
${selfCommand(paths, "stop")} stop the server
${selfCommand(paths, "rebuild")} stop, delete ${paths.root}, build and start again
(add --fresh for an empty home, or no flag for the test project)

How to test is in .agents/skills/j5-live-test/SKILL.md.
`;
Expand Down Expand Up @@ -474,12 +509,33 @@ function logTail(paths: LiveTestPaths): string {
return NodeFS.readFileSync(paths.log, "utf8").split("\n").slice(-40).join("\n");
}

function buildHome(paths: LiveTestPaths): void {
function buildHome(paths: LiveTestPaths, mode: LiveTestMode): void {
const rebuild = selfCommand(paths, `rebuild ${MODE_FLAGS[mode]}`);
if (NodeFS.existsSync(paths.root)) {
throw new LiveTestRefusal(
`${paths.root} exists but holds no finished build. Start over with: ${selfCommand(paths, "rebuild")}`,
`${paths.root} exists but holds no finished build. Start over with: ${rebuild}`,
);
}
if (mode === "fresh") {
NodeFS.mkdirSync(paths.home, { recursive: true });
// The same test repository as the default mode, but not registered: it is
// there to be added by hand when the wizard or a test asks for a project.
const repo = NodeChildProcess.spawnSync(
process.execPath,
[
NodePath.join(paths.worktree, "apps/server/src/j5/test-support/liveTestRepo.ts"),
paths.testRepo,
NodePath.join(paths.root, "test-repo-origin.git"),
],
{ cwd: paths.worktree, stdio: ["ignore", "ignore", "inherit"] },
);
if (repo.status !== 0) {
throw new LiveTestRefusal(`Building the test repository failed. Retry with: ${rebuild}`);
}
const manifest: Manifest = { mode: "fresh" };
NodeFS.writeFileSync(paths.manifest, `${JSON.stringify(manifest)}\n`);
return;
}
console.log("Building the test repository and home (about 30 seconds, no model calls)...");
const build = NodeChildProcess.spawnSync(
process.execPath,
Expand All @@ -495,9 +551,7 @@ function buildHome(paths: LiveTestPaths): void {
},
);
if (build.status !== 0) {
throw new LiveTestRefusal(
`Building the test home failed. Retry with: ${selfCommand(paths, "rebuild")}`,
);
throw new LiveTestRefusal(`Building the test home failed. Retry with: ${rebuild}`);
}
}

Expand Down Expand Up @@ -574,12 +628,42 @@ async function waitUntilReady(paths: LiveTestPaths): Promise<void> {
}
}

async function start(paths: LiveTestPaths): Promise<void> {
/**
* Why a start in `requested` mode must not use the home that is already
* built, or undefined when it may. A home's mode is fixed when it is built,
* so that nobody tests against data they did not ask for.
*/
export function modeRefusal(input: {
readonly requested: LiveTestMode;
readonly built: LiveTestMode;
readonly startCommand: (mode: LiveTestMode) => string;
readonly rebuildCommand: (mode: LiveTestMode) => string;
}): string | undefined {
if (input.requested === input.built) return undefined;
return `The live-test home here was built in ${input.built} mode, not ${input.requested} mode.\nUse it as built: ${input.startCommand(input.built)}\nOr replace it: ${input.rebuildCommand(input.requested)}`;
}

function builtMode(paths: LiveTestPaths): LiveTestMode | undefined {
return NodeFS.existsSync(paths.manifest) ? (readManifest(paths).mode ?? "default") : undefined;
}

async function start(paths: LiveTestPaths, mode: LiveTestMode): Promise<void> {
const built = builtMode(paths);
const refusal =
built === undefined
? undefined
: modeRefusal({
requested: mode,
built,
startCommand: (other) => selfCommand(paths, MODE_FLAGS[other]),
rebuildCommand: (other) => selfCommand(paths, `rebuild ${MODE_FLAGS[other]}`),
});
if (refusal !== undefined) throw new LiveTestRefusal(refusal);
const state = currentServerState(paths);
if (state.kind === "unverified") throw new LiveTestRefusal(state.reason);
// Already started, perhaps by a call that is still waiting: wait with it.
if (state.kind !== "stopped") return waitUntilReady(paths);
if (!NodeFS.existsSync(paths.manifest)) buildHome(paths);
if (built === undefined) buildHome(paths, mode);

const log = NodeFS.openSync(paths.log, "w");
// Detached makes the child the leader of a new process group, so its pid is
Expand Down Expand Up @@ -625,27 +709,45 @@ function status(paths: LiveTestPaths): void {
if (state.kind === "unverified") throw new LiveTestRefusal(state.reason);
if (state.kind === "running") return printRunning(paths, state.facts);
if (state.kind === "notServing") return console.log(notServingMessage(paths, state.reason));
const built = builtMode(paths);
console.log(
`J5 Code live-test server: STOPPED\n\n${
NodeFS.existsSync(paths.manifest)
? `The test home is built at ${paths.root}.`
: "No test home is built yet."
}\nStart it with: ${selfCommand(paths, "")}`,
built === undefined
? "No test home is built yet."
: `The test home is built at ${paths.root}, in ${built} mode.`
}\nStart it with: ${selfCommand(paths, MODE_FLAGS[built ?? "default"])}`,
);
}

export function parseCommand(args: ReadonlyArray<string>): LiveTestCommand {
const [command = "start", ...rest] = args;
if (rest.length > 0 || !COMMANDS.some((known) => known === command)) {
throw new LiveTestRefusal(
`Usage: node scripts/j5/setup-agent-live-test.ts [${COMMANDS.join(" | ")}]`,
);
const USAGE = `Usage: node scripts/j5/setup-agent-live-test.ts [--fresh]
node scripts/j5/setup-agent-live-test.ts rebuild [--fresh]
node scripts/j5/setup-agent-live-test.ts status | restart | stop`;

/**
* The command and, for a start or `rebuild`, the mode its flags ask for.
* `status`, `restart` and `stop` act on whatever is built and take no flags.
*/
export function parseCommand(args: ReadonlyArray<string>): {
readonly command: LiveTestCommand;
readonly mode: LiveTestMode;
} {
const flags = args.filter((argument) => argument.startsWith("--"));
const words = args.filter((argument) => !argument.startsWith("--"));
const command = words[0] ?? "start";
const takesMode = command === "start" || command === "rebuild";
if (
words.length > 1 ||
!COMMANDS.some((known) => known === command) ||
flags.some((flag) => flag !== MODE_FLAGS.fresh) ||
flags.length > (takesMode ? 1 : 0)
) {
throw new LiveTestRefusal(USAGE);
}
return command as LiveTestCommand;
return { command: command as LiveTestCommand, mode: flags.length === 1 ? "fresh" : "default" };
}

async function main(): Promise<void> {
const command = parseCommand(process.argv.slice(2));
const { command, mode } = parseCommand(process.argv.slice(2));
// oxlint-disable-next-line t3code/no-global-process-runtime -- a standalone CLI with no Effect runtime.
if (NodeOS.platform() !== "linux") {
throw new LiveTestRefusal("This script reads /proc and runs only on Linux.");
Expand All @@ -666,7 +768,8 @@ async function main(): Promise<void> {
if (command === "restart" || command === "rebuild") await stop(paths);
// resolveLiveTestPaths has confirmed this directory is inside the worktree's own .j5code.
if (command === "rebuild") NodeFS.rmSync(paths.root, { recursive: true, force: true });
await start(paths);
// A restart keeps the mode that is built; only a start or rebuild chooses one.
await start(paths, command === "restart" ? (builtMode(paths) ?? "default") : mode);
}

if (import.meta.main) {
Expand Down
Loading