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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,31 @@ an invalid config fails fast with a field-level error. See
and [`test/fixtures/factory.config.json`](test/fixtures/factory.config.json) for a
worked example (including offline fixture mode).

`cloneRoot` and every `clonePaths` value accept `~` or a leading `~/`, expanded
against the current user's home directory. Named-user forms such as `~alice`
are rejected because Node cannot expand them portably.

For a local, single-repository run, the checkout mapping can be omitted:

```json
{
"repos": {
"org": "your-org",
"names": ["your-repo"],
"default": "your-org/your-repo"
}
}
```

Run Factory from that repository (or one of its subdirectories). When exactly
one `repos.names` entry is configured and no `cloneRoot` or `clonePaths` field is
supplied, Factory resolves the checkout's git top-level and uses it only if a
GitHub remote matches the resolved `org/name`. The inference is logged. Missing,
unparseable, or mismatched remotes fail with a config-oriented error instead of
silently dispatching in the wrong directory. Explicit local clone paths are also
preflighted before commands that can dispatch through the internal backend;
relay-backend paths are left for their worker nodes to validate.

## Notes

- The daemon is headless by design; tools like Pear can consume this package and
Expand Down
155 changes: 155 additions & 0 deletions src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,156 @@ describe('fleet CLI runtime', () => {
expect(mount.writes).toEqual([])
})

it('infers clonePath from cwd for internal dispatch and logs the checkout root', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-infer-'))
try {
const configPath = await writeConfig(root, {
repos: {
org: 'AgentWorkforce',
names: ['pear'],
default: 'AgentWorkforce/pear',
},
})
const git = vi.fn(async (_cwd: string, args: string[]) => {
if (args[0] === 'rev-parse') return '/checkout/pear\n'
if (args[0] === 'remote' && args.length === 1) return 'origin\n'
if (args[0] === 'remote' && args[1] === 'get-url') return 'git@github.com:AgentWorkforce/pear.git\n'
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
const output = buffer()
const errors = buffer()

const code = await runFleetCli([
'dispatch',
'AR-77',
'--dry-run',
'--config',
configPath,
], {
fleet: new FakeFleetClient(),
mount: new FakeMountClient({ [issuePath]: issueFile }),
localClonePathOptions: { cwd: '/checkout/pear/src', git },
stdout: output,
stderr: errors,
})

expect(code).toBe(0)
expect(JSON.parse(output.text())).toMatchObject({
issue: { key: 'AR-77' },
dryRun: true,
})
expect(errors.text()).toContain('[factory] clonePath inferred from cwd: /checkout/pear')
expect(git).toHaveBeenCalledWith('/checkout/pear/src', ['rev-parse', '--show-toplevel'])
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('fails fast for an invalid explicit clonePath before internal dispatch', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-invalid-clone-'))
try {
const missing = join(root, 'missing-checkout')
const configPath = await writeConfig(root, {
repos: {
byLabel: { pear: 'AgentWorkforce/pear' },
clonePaths: { 'AgentWorkforce/pear': missing },
default: 'AgentWorkforce/pear',
},
})
const output = buffer()
const errors = buffer()

const code = await runFleetCli([
'dispatch',
'AR-77',
'--dry-run',
'--config',
configPath,
], {
fleet: new FakeFleetClient(),
mount: new FakeMountClient({ [issuePath]: issueFile }),
localClonePathOptions: { validateConfiguredCheckouts: true },
stdout: output,
stderr: errors,
})

expect(code).toBe(1)
expect(errors.text()).toContain(`[factory] clonePath for AgentWorkforce/pear does not exist: ${missing}`)
expect(output.text()).toBe('')
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('does not inspect orchestrator-local checkouts for relay dispatch', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-relay-clone-'))
try {
const configPath = await writeConfig(root, {
repos: {
byLabel: { pear: 'AgentWorkforce/pear' },
clonePaths: { 'AgentWorkforce/pear': join(root, 'worker-only-checkout') },
default: 'AgentWorkforce/pear',
},
})
const git = vi.fn(async () => {
throw new Error('relay dispatch must not inspect local git state')
})

const code = await runFleetCli([
'dispatch',
'AR-77',
'--dry-run',
'--backend',
'relay',
'--config',
configPath,
], {
fleet: new FakeFleetClient(),
mount: new FakeMountClient({ [issuePath]: issueFile }),
localClonePathOptions: { git, validateConfiguredCheckouts: true },
stdout: buffer(),
stderr: buffer(),
})

expect(code).toBe(0)
expect(git).not.toHaveBeenCalled()
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('does not infer or preflight clone paths for a maintenance command', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-maintenance-clone-'))
try {
const configPath = await writeConfig(root, {
repos: { org: 'AgentWorkforce', names: ['pear'] },
})
const git = vi.fn(async () => {
throw new Error('status must not inspect local git state')
})
const factoryStatus = { inFlight: [], queued: [], counters: { pulled: 0 } }
const factory = {
status: vi.fn(() => factoryStatus),
} as unknown as Factory
const output = buffer()

const code = await runFleetCli(['status', '--config', configPath], {
fleet: new FakeFleetClient(),
mount: new FakeMountClient(),
createFactory: () => factory,
localClonePathOptions: { cwd: '/not/a/checkout', git, validateConfiguredCheckouts: true },
stdout: output,
stderr: buffer(),
})

expect(code).toBe(0)
expect(JSON.parse(output.text())).toEqual(factoryStatus)
expect(git).not.toHaveBeenCalled()
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('auto-detects GitHub-only workspaces without resolving Linear states', async () => {
const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-only-'))
try {
Expand Down Expand Up @@ -705,6 +855,9 @@ describe('fleet CLI runtime', () => {
it('runs manual close-probe through the injectable probe closer', async () => {
const output = buffer()
const calls: unknown[] = []
const git = vi.fn(async () => {
throw new Error('close-probe must not inspect local git state')
})
const code = await runFleetCli([
'close-probe',
'42',
Expand All @@ -715,13 +868,15 @@ describe('fleet CLI runtime', () => {
], {
stdout: output,
stderr: buffer(),
localClonePathOptions: { git, validateConfiguredCheckouts: true },
probeCloser: async (input: Pick<CloseProbePrInput, 'repo' | 'prNumber' | 'expectedIssueKey'>) => {
calls.push(input)
return { repo: input.repo, prNumber: input.prNumber, state: 'CLOSED' }
},
})

expect(code).toBe(0)
expect(git).not.toHaveBeenCalled()
expect(calls).toEqual([{ repo: 'AgentWorkforce/pear', prNumber: 42, expectedIssueKey: 'AR-77' }])
expect(JSON.parse(output.text())).toEqual({ repo: 'AgentWorkforce/pear', prNumber: 42, state: 'CLOSED' })
})
Expand Down
43 changes: 39 additions & 4 deletions src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'

import { ensureLocalMount } from '../mount/local-mount-preflight'
import { resolveLocalFactoryConfig, type LocalClonePathOptions } from '../config/local-clone-paths'
import {
FactoryConfigSchema,
FileStateStore,
RelayfileCloudMountClient,
checkFactoryLoopLiveness,
Expand Down Expand Up @@ -72,6 +72,8 @@ interface FleetCliDeps {
stopSignalProcessLike?: Pick<NodeJS.Process, 'once' | 'off'>
daemonExit?: (code: number) => void
flushDaemonOutput?: () => Promise<void>
/** Hermetic local-checkout probes for CLI integration tests. */
localClonePathOptions?: LocalClonePathOptions
}

interface GlobalOptions {
Expand Down Expand Up @@ -130,7 +132,17 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom
return 0
}

const loaded = command.kind.startsWith('factory') ? await loadConfig(globals.config) : undefined
const resolvesLocalClonePaths = globals.backend === 'internal' && commandUsesLocalCheckout(command)
const loaded = command.kind.startsWith('factory')
? await loadConfig(globals.config, {
...deps.localClonePathOptions,
inferFromCwd: resolvesLocalClonePaths,
logger: streamLogger(err),
validateConfiguredCheckouts: resolvesLocalClonePaths && (
deps.localClonePathOptions?.validateConfiguredCheckouts ?? !hasInjectedFactoryRuntime(deps)
),
})
: undefined
fleet = await buildFleet(globals, loaded, deps)

switch (command.kind) {
Expand Down Expand Up @@ -718,16 +730,39 @@ function parseFactoryStartFlags(args: Array<string | undefined>): { mode: 'live'
return { mode }
}

async function loadConfig(path?: string): Promise<LoadedConfig> {
async function loadConfig(path?: string, options: LocalClonePathOptions = {}): Promise<LoadedConfig> {
const configPath = path ?? resolve(process.cwd(), 'factory.config.json')
const raw = JSON.parse(await readFile(configPath, 'utf8')) as unknown
const record = asRecord(raw)
return {
config: FactoryConfigSchema.parse(record.factoryConfig ?? record),
config: await resolveLocalFactoryConfig(raw, {
...options,
// fixtureFiles describe a hermetic in-memory run; their checkout paths
// are intentionally synthetic and must not be preflighted on the host.
validateConfiguredCheckouts: options.validateConfiguredCheckouts && !record.fixtureFiles,
}),
fixtureFiles: record.fixtureFiles ? asRecord(record.fixtureFiles) : undefined,
}
}

function commandUsesLocalCheckout(command: ParsedCommand): boolean {
switch (command.kind) {
case 'factory':
return command.action === 'run-once' || command.action === 'loop' || command.action === 'start'
case 'factory-canary':
case 'factory-triage':
case 'factory-dispatch':
case 'factory-babysit':
return true
default:
return false
}
}

function hasInjectedFactoryRuntime(deps: FleetCliDeps): boolean {
return Boolean(deps.fleet || deps.mount || deps.createFactory || deps.createFleet || deps.cloudMountFromConfig)
}

async function buildFleet(
globals: GlobalOptions,
loaded: LoadedConfig | undefined,
Expand Down
Loading