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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ From a source checkout instead of an npm install, run

## Quick start

From the repository checkout you want Factory to work in, the quickest setup is:

```bash
factory init
```

It derives the repository from `origin`, checks for `agent-relay` and
`relayfile`, finds the active Relay workspace, starts the local mount, verifies
GitHub access, and creates `factory.config.json` for GitHub-native issues. If
something is missing, it explains what to do and writes no partial config. Use
`factory init owner/repo` when the checkout has no GitHub `origin`, or add
`--workspace <id>` to choose a workspace explicitly.

After init, add the `factory` label to an open issue and run a dry run below.

1. **Connect GitHub to your relay workspace** with push access for the target
repositories. Factory uses that workspace connection to publish branches and
open pull requests by default. A local `gh` installation and `gh auth login`
Expand Down Expand Up @@ -116,6 +131,7 @@ From a source checkout instead of an npm install, run

| Command | What it does |
|---|---|
| `factory init [owner/repo]` | Verify local Relay prerequisites and GitHub access, then configure the current checkout for GitHub-native issue dispatch. |
| `factory run-once` | One discover→triage→dispatch cycle, then exit. Honors `--dry-run`. |
| `factory loop` | A bounded multi-iteration loop, then exit. |
| `factory start --mode live` | Long-lived daemon — the production entrypoint. Runs until you stop it. |
Expand Down
8 changes: 8 additions & 0 deletions src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,14 @@ describe('fleet CLI parsing', () => {
})
})

it('parses init with an explicit repository and workspace', () => {
expect(parseFleetCommand(['init', 'Acme/widgets', '--workspace', 'rw_widgets'])).toEqual({
kind: 'factory-init',
repo: 'Acme/widgets',
workspaceId: 'rw_widgets',
})
})

it('defaults factory start to live mode', () => {
expect(parseFleetCommand(['start'])).toEqual({
kind: 'factory',
Expand Down
28 changes: 27 additions & 1 deletion src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ensureCloudSession, type CloudSession } from '@agent-relay/cloud'

import { stringifyLogValue } from '../logging'
import { resolveLocalFactoryConfig, type LocalClonePathOptions } from '../config/local-clone-paths'
import { initializeFactory } from './init'
import {
FileStateStore,
RelayfileCloudMountClient,
Expand Down Expand Up @@ -134,6 +135,7 @@ type ParsedCommand =
| { kind: 'factory-babysit'; prNumber: number; repo?: string; url?: string }
| { kind: 'factory-close-probe'; prNumber: number; repo: string; issue: string }
| { kind: 'featuremap-check'; manifestPath?: string; baseRef?: string }
| { kind: 'factory-init'; repo?: string; workspaceId?: string }

export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Promise<number> {
const out = deps.stdout ?? process.stdout
Expand Down Expand Up @@ -163,6 +165,11 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom
return 0
}

if (command.kind === 'factory-init') {
await initializeFactory({ repo: command.repo, workspaceId: command.workspaceId, stdout: out, stderr: err })
return 0
}

if (command.kind === 'factory-close-probe') {
// Manual close-probe remains strict; the daemon relaxes the title marker only after issue-synthetic classification.
let githubWrite
Expand Down Expand Up @@ -948,6 +955,22 @@ function resolveLocalMountFn(

function parseFactoryCommand(args: string[]): ParsedCommand {
const [action, issueOrPr, ...flags] = args
if (action === 'init') {
const values = [issueOrPr, ...flags].filter((value): value is string => Boolean(value))
let repo: string | undefined
let workspaceId: string | undefined
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (value === '--workspace') {
workspaceId = requireValue(values, ++index, '--workspace')
continue
}
if (value.startsWith('-')) throw new Error(`Unknown factory init option: ${value}`)
if (repo) throw new Error('factory init accepts at most one owner/repo argument')
repo = value
}
return { kind: 'factory-init', repo, workspaceId }
}
if (action === 'start') {
return { kind: 'factory', action, ...parseFactoryStartFlags([issueOrPr, ...flags]) }
}
Expand Down Expand Up @@ -1707,7 +1730,8 @@ function isCapability(value: string | undefined): value is Capability {
}

function isFactoryAction(value: string): boolean {
return value === 'start' ||
return value === 'init' ||
value === 'start' ||
value === 'run-once' ||
value === 'loop' ||
value === 'status' ||
Expand Down Expand Up @@ -1783,6 +1807,7 @@ function helpText(): string {
return `${usage()}

Commands:
init [owner/repo] Set up this checkout for GitHub-native issue dispatch
run-once Run one discovery -> triage -> dispatch cycle
start Run the live factory daemon
status Print current factory status as JSON
Expand All @@ -1799,6 +1824,7 @@ Commands:
fleet <command> Low-level fleet commands: spawn, roster, release

Options:
--workspace <id> Relay workspace to use with init (otherwise active workspace)
--config <path> Factory config JSON path (default: ./factory.config.json)
--dry-run Discover and triage without writes or agent spawns
--backend <backend> Fleet backend: internal or relay
Expand Down
100 changes: 100 additions & 0 deletions src/cli/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'

import { FakeMountClient } from '../testing'
import { initializeFactory } from './init'

describe('factory init', () => {
it('creates a GitHub-native config for the repository in the checkout', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'factory-init-'))
try {
const output = buffer()
const result = await initializeFactory({
cwd,
stdout: output,
stderr: buffer(),
gitRemote: async () => 'git@github.com:Acme/widgets.git',
commandExists: async () => true,
resolveWorkspace: async () => ({ workspaceId: 'rw_widgets', cloudWorkspaceId: 'workspace-uuid' }),
ensureLocalMount: async () => {},
cloudMountFromConfig: async () => new FakeMountClient(),
})

expect(result).toEqual({ configPath: join(cwd, 'factory.config.json'), repo: 'Acme/widgets', workspaceId: 'rw_widgets' })
expect(JSON.parse(await readFile(result.configPath, 'utf8'))).toEqual({
workspaceId: 'rw_widgets',
issueSource: 'github',
repos: {
org: 'Acme',
names: ['widgets'],
default: 'Acme/widgets',
clonePaths: { 'Acme/widgets': cwd },
},
})
expect(output.text()).toContain('Add the `factory` label')
} finally {
await rm(cwd, { recursive: true, force: true })
}
})

it('does not write a config when a dependency or GitHub connection is missing', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'factory-init-missing-'))
try {
await expect(initializeFactory({
cwd,
repo: 'Acme/widgets',
commandExists: async (command) => command === 'relayfile',
stderr: buffer(),
})).rejects.toThrow('Missing required dependency')

await expect(readFile(join(cwd, 'factory.config.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })

await expect(initializeFactory({
cwd,
repo: 'Acme/widgets',
commandExists: async () => true,
resolveWorkspace: async () => ({ workspaceId: 'rw_widgets' }),
ensureLocalMount: async () => {},
cloudMountFromConfig: async () => {
const mount = new FakeMountClient()
mount.setSubRoot('/github/repos/Acme/widgets', 'absent')
return mount
},
stderr: buffer(),
})).rejects.toThrow('GitHub connection')
await expect(readFile(join(cwd, 'factory.config.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
await rm(cwd, { recursive: true, force: true })
}
})

it('will not replace an existing config', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'factory-init-existing-'))
try {
const path = join(cwd, 'factory.config.json')
await writeFile(path, '{"keep":true}\n')
await expect(initializeFactory({
cwd,
repo: 'Acme/widgets',
commandExists: async () => true,
resolveWorkspace: async () => ({ workspaceId: 'rw_widgets' }),
})).rejects.toThrow('Refusing to overwrite')
expect(await readFile(path, 'utf8')).toBe('{"keep":true}\n')
} finally {
await rm(cwd, { recursive: true, force: true })
}
})
})

function buffer(): Pick<NodeJS.WriteStream, 'write'> & { text: () => string } {
let contents = ''
return {
write: (chunk: string | Uint8Array) => {
contents += String(chunk)
return true
},
text: () => contents,
}
}
143 changes: 143 additions & 0 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { access, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import { spawn } from 'node:child_process'
import { join, resolve } from 'node:path'

import {
RelayfileCloudMountClient,
resolveFactoryWorkspace,
type MountClient,
type ResolvedFactoryWorkspace,
} from '../index'

type FactoryInitMount = MountClient & {
ensureLocalMount?: (startDir: string, options?: { acceptableWorkspaceIds?: readonly string[] }) => Promise<void>
}

export interface FactoryInitOptions {
cwd?: string
repo?: string
workspaceId?: string
stdout?: Pick<NodeJS.WriteStream, 'write'>
stderr?: Pick<NodeJS.WriteStream, 'write'>
commandExists?: (command: string) => Promise<boolean>
gitRemote?: (cwd: string) => Promise<string | undefined>
resolveWorkspace?: () => Promise<ResolvedFactoryWorkspace>
cloudMountFromConfig?: (config: { workspaceId: string }) => Promise<FactoryInitMount>
ensureLocalMount?: (workspaceId: string, cwd: string, options?: { acceptableWorkspaceIds?: readonly string[] }) => Promise<void>
}

export interface FactoryInitResult {
configPath: string
repo: string
workspaceId: string
}

/**
* Make the current checkout dispatch GitHub-native issues. The checks happen
* before writing config so a successful init is immediately runnable.
*/
export async function initializeFactory(options: FactoryInitOptions = {}): Promise<FactoryInitResult> {
const cwd = resolve(options.cwd ?? process.cwd())
const out = options.stdout ?? process.stdout
const err = options.stderr ?? process.stderr
const repo = validateRepo(options.repo ?? repoFromRemote(await (options.gitRemote ?? originRemote)(cwd)))
const commandExists = options.commandExists ?? executableOnPath

const missing = (await Promise.all(['agent-relay', 'relayfile'].map(async (command) => (
await commandExists(command) ? undefined : command
)))).filter((command): command is string => command !== undefined)
if (missing.length > 0) {
err.write('Factory needs a couple of local tools before it can run:\n')
for (const command of missing) {
err.write(` - ${command} is not installed or is not on PATH\n`)
}
err.write('Install and sign in to those tools, then run `factory init` again. No files were changed.\n')
throw new Error(`Missing required dependency${missing.length === 1 ? '' : 'ies'}: ${missing.join(', ')}`)
}

const workspace = options.workspaceId
? { workspaceId: options.workspaceId }
: await (options.resolveWorkspace ?? resolveFactoryWorkspace)()
const workspaceId = workspace.workspaceId
if (!workspaceId) throw new Error('Could not find an active Relay workspace. Sign in to relayfile, or run `factory init --workspace <workspace-id>`.')

const configPath = join(cwd, 'factory.config.json')
await assertAbsent(configPath)

out.write(`Checking ${repo} in Relay workspace ${workspaceId}…\n`)
const mount = await (options.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({ workspaceId })
const mountOptions = {
acceptableWorkspaceIds: 'cloudWorkspaceId' in workspace && workspace.cloudWorkspaceId ? [workspace.cloudWorkspaceId] : undefined,
}
if (options.ensureLocalMount) {
await options.ensureLocalMount(workspaceId, cwd, mountOptions)
} else if (mount.ensureLocalMount) {
await mount.ensureLocalMount(cwd, mountOptions)
} else {
throw new Error('The connected Relay workspace cannot start a local mount. Update @agent-relay/factory and run `factory init` again.')
}
const githubStatus = await mount.ensureSubRoot(`/github/repos/${repo}`, { timeoutMs: 90_000 })
if (githubStatus !== 'ready') {
err.write(`GitHub repository ${repo} is not connected to this Relay workspace.\n`)
err.write('Connect GitHub with push access for this repository in Agent Relay, wait for the repository to sync, then run `factory init` again. No config was written.\n')
throw new Error(`GitHub connection for ${repo} is not ready (${githubStatus})`)
}

const [owner, name] = repo.split('/') as [string, string]
const config = {
workspaceId,
issueSource: 'github',
repos: {
org: owner,
names: [name],
default: repo,
clonePaths: { [repo]: cwd },
},
}
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
out.write(`Factory is ready for ${repo}. Created ${configPath}.\n`)
out.write('Add the `factory` label to an open GitHub issue, then run `factory run-once --dry-run`.\n')
return { configPath, repo, workspaceId }
}

function validateRepo(value: string | undefined): string {
if (!value || !/^[^/\s]+\/[^/\s]+$/u.test(value)) {
throw new Error('Could not determine the GitHub repository. Run `factory init owner/repo` from a Git checkout, or pass an explicit owner/repo.')
}
return value
}

function repoFromRemote(remote: string | undefined): string | undefined {
if (!remote) return undefined
const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/u.exec(remote)
const https = /^https?:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/u.exec(remote)
return ssh?.[1] ?? https?.[1]
}

async function assertAbsent(path: string): Promise<void> {
try {
await access(path, constants.F_OK)
throw new Error(`Refusing to overwrite existing ${path}. Edit it manually or move it aside before running factory init.`)
} catch (error) {
if (error instanceof Error && error.message.startsWith('Refusing')) throw error
}
}

function originRemote(cwd: string): Promise<string | undefined> {
return new Promise((resolveRemote) => {
const child = spawn('git', ['config', '--get', 'remote.origin.url'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
const chunks: Buffer[] = []
child.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
child.on('close', (code) => resolveRemote(code === 0 ? Buffer.concat(chunks).toString('utf8').trim() || undefined : undefined))
child.on('error', () => resolveRemote(undefined))
})
}

function executableOnPath(command: string): Promise<boolean> {
return new Promise((resolveExists) => {
const child = spawn(command, ['--version'], { stdio: 'ignore' })
child.on('close', (code) => resolveExists(code === 0))
child.on('error', () => resolveExists(false))
})
}