Skip to content
Open
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,8 @@
"name": "node",
"version": "24.17.0"
}
},
"volta": {
"node": "24.17.0"
}
}
15 changes: 15 additions & 0 deletions packages/software-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ The orchestrator (`runIssueLoop`) is a thin scheduler that picks the next unbloc

## Prerequisites

- Node >= 24 (the repo pins `24.17.0` via `.nvmrc` / Volta — run `nvm use` or let Volta pick it up)
- **One-time build/setup** — on a fresh `pnpm install`-only checkout, run:

```bash
cd packages/software-factory
pnpm factory:setup
```

This is idempotent (skips anything already built) and provisions the three
artifacts the factory needs that aren't committed: the boxel-cli API bundle
(`boxel-cli/dist/api.js`), a dev-mode host build with test entries
(`host/dist/tests/index.html`), and the Playwright Chromium headless-shell
binary. `pnpm factory:go` also detects any missing prerequisite up front and
points you back here. Pass `--force` to rebuild everything.

- Docker running
- `mise run dev-all` (starts realm server, host app, icons server, Postgres, Synapse)
- Active Boxel CLI profile (`boxel profile add`)
Expand Down
1 change: 1 addition & 0 deletions packages/software-factory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"boxel:search": "NODE_NO_WARNINGS=1 node scripts/boxel-search.ts",
"cache:prepare": "NODE_NO_WARNINGS=1 node src/cli/cache-realm.ts",
"factory:go": "NODE_NO_WARNINGS=1 node src/cli/factory-entrypoint.ts",
"factory:setup": "NODE_NO_WARNINGS=1 node scripts/factory-setup.ts",
"smoke:context": "NODE_NO_WARNINGS=1 node scripts/smoke-tests/factory-context-smoke.ts",
"smoke:prompt": "NODE_NO_WARNINGS=1 node scripts/smoke-tests/factory-prompt-smoke.ts",
"smoke:skill": "NODE_NO_WARNINGS=1 node scripts/smoke-tests/factory-skill-smoke.ts",
Expand Down
99 changes: 99 additions & 0 deletions packages/software-factory/scripts/factory-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// This should be first
import '../src/setup-logger.ts';

// Idempotent bootstrap for `pnpm factory:go` on a fresh checkout (CS-12186).
// Builds the boxel-cli API bundle, builds the host app in dev mode (so the
// test harness has its `dist/tests/index.html` entry), and downloads the
// Playwright Chromium headless-shell binary — skipping any step whose artifact
// is already present. Node itself can't be installed for you, so a too-old Node
// is reported as an error rather than a build step.
//
// Pass --force to rebuild/reinstall everything regardless of what's present.

import { spawnSync } from 'node:child_process';

import { logger } from '../src/logger.ts';
import {
MIN_NODE_MAJOR,
PINNED_NODE,
boxelCliApiJsExists,
hostTestHarnessExists,
isNodeVersionOk,
packageRoot,
playwrightHeadlessShellPresent,
repoRoot,
} from '../src/preflight.ts';

let log = logger('factory-setup');

function run(label: string, cmd: string, args: string[], cwd: string): void {
log.info(`▶ ${label}`);
log.info(` ${cmd} ${args.join(' ')}`);
let result = spawnSync(cmd, args, {
cwd,
stdio: 'inherit',
// pnpm/npx resolve through the shell on Windows.
shell: process.platform === 'win32',
});
if (result.status !== 0) {
log.error(`✗ ${label} failed (exit ${result.status ?? 'signal'})`);
process.exit(result.status ?? 1);
}
}

function main(): void {
let force = process.argv.slice(2).includes('--force');
let root = repoRoot();

// Node can't be provisioned by a build step — fail early with guidance.
if (!isNodeVersionOk()) {
log.error(
`Node ${process.versions.node} detected, but the factory requires Node >= ${MIN_NODE_MAJOR}.`,
);
log.error(
`Install it (the repo pins ${PINNED_NODE} via .nvmrc / devEngines), then re-run pnpm factory:setup.`,
);
process.exit(1);
}

if (force || !boxelCliApiJsExists()) {
run(
'Build boxel-cli API bundle (dist/api.js)',
'pnpm',
['--filter', '@cardstack/boxel-cli', 'build:api'],
root,
);
} else {
log.info('✓ boxel-cli dist/api.js present — skipping');
}

if (force || !hostTestHarnessExists()) {
run(
'Build host app (dev mode, with test entries)',
'pnpm',
['--filter', '@cardstack/host', 'build'],
root,
);
} else {
log.info('✓ host dist/tests/index.html present — skipping');
}

if (force || !playwrightHeadlessShellPresent()) {
// Run from this package (its node_modules has @playwright/test).
run(
'Install Playwright Chromium (headless shell)',
'pnpm',
['exec', 'playwright', 'install', 'chromium-headless-shell'],
packageRoot(),
);
} else {
log.info('✓ Playwright chromium-headless-shell present — skipping');
}

log.info('✓ factory:setup complete.');
log.info(
' Run: pnpm factory:go --brief-url <url> --target-realm <url> --debug',
);
}

main();
179 changes: 121 additions & 58 deletions packages/software-factory/src/cli/factory-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,147 @@
// This should be first
import '../setup-logger.ts';

import { BoxelCLIClient } from '@cardstack/boxel-cli/api';

import {
FactoryEntrypointUsageError,
getFactoryEntrypointUsage,
parseFactoryEntrypointArgs,
runFactoryEntrypoint,
wantsFactoryEntrypointHelp,
} from '../factory-entrypoint.ts';
import { FactoryBriefError } from '../factory-brief.ts';
import { configureLogger, logger, setLogTimestampsEnabled } from '../logger.ts';
import {
formatMissingPrerequisites,
missingPrerequisites,
} from '../preflight.ts';

let log = logger('factory-entrypoint');

function wantsHelp(argv: string[]): boolean {
let normalized = argv[0] === '--' ? argv.slice(1) : argv;
return normalized.includes('--help');
}

async function main(): Promise<void> {
if (wantsFactoryEntrypointHelp(process.argv.slice(2))) {
console.log(getFactoryEntrypointUsage());
return;
let argv = process.argv.slice(2);
let helpRequested = wantsHelp(argv);

// Preflight BEFORE importing anything from @cardstack/boxel-cli. That import
// resolves to boxel-cli/dist/api.js — one of the build artifacts a fresh
// checkout is missing (CS-12186) — so a missing prerequisite would otherwise
// crash at module load with an opaque ERR_MODULE_NOT_FOUND before any of our
// code runs. Report every missing prerequisite at once and point at
// `pnpm factory:setup`. Skipped for --help so usage stays available on an
// otherwise-provisioned checkout.
if (!helpRequested) {
let missing = missingPrerequisites();
if (missing.length > 0) {
log.error(formatMissingPrerequisites(missing));
process.exitCode = 1;
return;
}
}

let options = parseFactoryEntrypointArgs(process.argv.slice(2));

// --debug raises the log level so debug-gated lines (e.g. full
// run-command response bodies) surface, unless the caller has already
// pinned a level via LOG_LEVELS. It also turns on the timing
// instrumentation (per-line timestamps + per-phase/summary durations),
// which is otherwise off so normal runs stay clean.
if (options.debug) {
setLogTimestampsEnabled(true);
if (!process.env.LOG_LEVELS) {
configureLogger('*=debug');
// Deferred imports: only load boxel-cli (and the rest of the entrypoint,
// which imports it transitively) once preflight has confirmed dist/api.js
// exists.
let entrypoint;
let briefModule;
let cli;
try {
[entrypoint, briefModule, cli] = await Promise.all([
import('../factory-entrypoint.ts'),
import('../factory-brief.ts'),
import('@cardstack/boxel-cli/api'),
]);
} catch (error) {
// The only expected failure here is --help on a checkout that hasn't built
// dist/api.js yet (preflight is skipped for --help). Re-run the check so
// the user still gets the actionable list instead of a raw module error.
let missing = missingPrerequisites();
if (missing.length > 0) {
log.error(formatMissingPrerequisites(missing));
process.exitCode = 1;
return;
}
throw error;
}

await BoxelCLIClient.ensureProfile({
realmServerUrl: options.realmServerUrl ?? undefined,
});
let {
FactoryEntrypointUsageError,
getFactoryEntrypointUsage,
parseFactoryEntrypointArgs,
runFactoryEntrypoint,
wantsFactoryEntrypointHelp,
} = entrypoint;
let { FactoryBriefError } = briefModule;
let { BoxelCLIClient } = cli;

log.info(`brief=${options.briefUrl}`);
log.info('Starting seed issue + issue-driven loop...');
try {
if (wantsFactoryEntrypointHelp(argv)) {
console.log(getFactoryEntrypointUsage());
return;
}

let options = parseFactoryEntrypointArgs(argv);

// --debug raises the log level so debug-gated lines (e.g. full
// run-command response bodies) surface, unless the caller has already
// pinned a level via LOG_LEVELS. It also turns on the timing
// instrumentation (per-line timestamps + per-phase/summary durations),
// which is otherwise off so normal runs stay clean.
if (options.debug) {
setLogTimestampsEnabled(true);
if (!process.env.LOG_LEVELS) {
configureLogger('*=debug');
}
}

let summary = await runFactoryEntrypoint(options);
await BoxelCLIClient.ensureProfile({
realmServerUrl: options.realmServerUrl ?? undefined,
});

if (summary.issueLoop) {
log.info(
`Issue loop complete: outcome=${summary.issueLoop.outcome} ` +
`outerCycles=${summary.issueLoop.outerCycles} ` +
`issues=${summary.issueLoop.issueResults.length}`,
);
for (let ir of summary.issueLoop.issueResults) {
log.info(`brief=${options.briefUrl}`);
log.info('Starting seed issue + issue-driven loop...');

let summary = await runFactoryEntrypoint(options);

if (summary.issueLoop) {
log.info(
` ${ir.issueId}: ${ir.exitReason} (${ir.innerIterations} iterations, ${ir.toolCallCount} tool calls)`,
`Issue loop complete: outcome=${summary.issueLoop.outcome} ` +
`outerCycles=${summary.issueLoop.outerCycles} ` +
`issues=${summary.issueLoop.issueResults.length}`,
);
for (let ir of summary.issueLoop.issueResults) {
log.info(
` ${ir.issueId}: ${ir.exitReason} (${ir.innerIterations} iterations, ${ir.toolCallCount} tool calls)`,
);
}
if (summary.issueLoop.outcome === 'all_issues_done') {
log.info('All issues done.');
} else {
log.info(`Exiting with outcome=${summary.issueLoop.outcome}`);
}
}

// Reflect the run outcome in the exit code so CI / callers can detect
// failures without parsing logs. `completed` is the only success state;
// `ready` means the entrypoint returned before the loop ran, which we
// treat as a failed invocation.
if (summary.result.status !== 'completed') {
process.exitCode = 1;
}

if (options.debug) {
await writeToStdout(JSON.stringify(summary, null, 2) + '\n');
}
if (summary.issueLoop.outcome === 'all_issues_done') {
log.info('All issues done.');
} catch (error: unknown) {
if (error instanceof FactoryEntrypointUsageError) {
log.error(error.message);
log.error('');
log.error(getFactoryEntrypointUsage());
} else if (error instanceof FactoryBriefError) {
log.error(error.message);
} else if (error instanceof Error) {
log.error(error.stack ?? error.message);
} else {
log.info(`Exiting with outcome=${summary.issueLoop.outcome}`);
log.error(String(error));
}
}

// Reflect the run outcome in the exit code so CI / callers can detect
// failures without parsing logs. `completed` is the only success state;
// `ready` means the entrypoint returned before the loop ran, which we
// treat as a failed invocation.
if (summary.result.status !== 'completed') {
process.exitCode = 1;
}

if (options.debug) {
await writeToStdout(JSON.stringify(summary, null, 2) + '\n');
}
}

// Large summaries can exceed the stdout high-water mark, in which case
Expand All @@ -93,13 +162,7 @@ function writeToStdout(chunk: string): Promise<void> {

main()
.catch((error: unknown) => {
if (error instanceof FactoryEntrypointUsageError) {
log.error(error.message);
log.error('');
log.error(getFactoryEntrypointUsage());
} else if (error instanceof FactoryBriefError) {
log.error(error.message);
} else if (error instanceof Error) {
if (error instanceof Error) {
log.error(error.stack ?? error.message);
} else {
log.error(String(error));
Expand Down
Loading
Loading