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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ hatch is deliberately page-specific; there is no title or content heuristic.
"version": 1,
"mountRoot": ".integrations/notion",
"workerMountRoot": ".integrations/notion",
"workerMountTransport": { "kind": "relay-channel" },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"statePath": ".factory/notion-intake-state.json",
"tasks": [
{ "page": "https://app.notion.com/p/Reconcile-3b36800c1c90801db1cfc8f2e1cff7cf" }
Expand All @@ -206,8 +207,15 @@ hatch is deliberately page-specific; there is no title or content heuristic.
```

`mountRoot` and `statePath` resolve relative to the manifest file. `workerMountRoot`
is the repo-relative read-only mount workers receive; all three default to the
values shown above. `page` accepts a Notion URL or a bare page ID.
is the repo-relative read-only mount workers receive. With the recommended
`relay-channel` transport, Factory base64-chunks the digest-bound mounted bytes
into a workspace-private Agent Relay channel. A worker on any fleet machine can
reconstruct the exact file at `workerMountRoot`, set it to mode `0444`, and
apply the source SHA-256 gate without exposing the page in a public issue.
The field defaults to `{ "kind": "local" }` whenever it is omitted, including
in new manifests. Portable delivery must be selected explicitly and requires a
resolvable active Agent Relay workspace key; otherwise dispatch fails closed.
`page` accepts a Notion URL or a bare page ID.

Plan without writes, then dispatch:

Expand Down
78 changes: 76 additions & 2 deletions src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,12 +582,13 @@ describe('fleet CLI runtime', () => {
`Project-Paths: ${projectPath}`,
].join('\n'))
const manifestPath = join(root, 'notion.json')
await writeFile(manifestPath, JSON.stringify({
const manifest = {
version: 1,
mountRoot: './notion',
statePath: './state.json',
tasks: [{ page: '3b36800c1c90801db1cfc8f2e1cff7cf' }],
}))
}
await writeFile(manifestPath, JSON.stringify(manifest))
const output = buffer()
const fleet = new FakeFleetClient()

Expand All @@ -603,6 +604,79 @@ describe('fleet CLI runtime', () => {
ok: true,
results: [{ status: 'dispatched', target: { projectPath } }],
})

await writeFile(manifestPath, JSON.stringify({
...manifest,
workerMountTransport: { kind: 'relay-channel' },
}))
const contracts = {
publish: vi.fn(async () => ({
kind: 'relay-channel' as const,
channel: 'factory-notion-e1cff7cf-aabbccddee',
messageIds: ['message-1'],
encoding: 'base64-chunks-v1' as const,
})),
dispose: vi.fn(async () => { throw new Error('cleanup failed') }),
}
const migratedOutput = buffer()
const migratedErrors = buffer()
const migratedCode = await runFleetCli(['intake', 'notion', manifestPath], {
fleet,
notionContracts: contracts,
env: {},
stdout: migratedOutput,
stderr: migratedErrors,
})

expect(migratedCode).toBe(0)
expect(fleet.spawns).toHaveLength(1)
expect(fleet.messages).toEqual([expect.objectContaining({
to: expect.stringContaining('notion-e1cff7cf'),
text: expect.stringContaining('factory-notion-e1cff7cf-aabbccddee'),
mode: 'steer',
})])
expect(contracts.dispose).toHaveBeenCalledOnce()
expect(migratedErrors.text()).toContain('Notion contract publisher failed during shutdown')
expect(JSON.parse(migratedOutput.text())).toMatchObject({
ok: true,
results: [{ status: 'already-dispatched', target: { projectPath } }],
})
} finally {
await rm(root, { recursive: true, force: true })
}
})

it('does not borrow an ambient workspace when an explicit intake environment has no key', async () => {
const root = await mkdtemp(join(tmpdir(), 'factory-cli-notion-no-key-'))
try {
const mountedPage = join(root, 'notion', 'pages', '3b36800c-1c90-801d-b1cf-c8f2e1cff7cf')
await mkdir(mountedPage, { recursive: true })
await writeFile(join(mountedPage, 'content.md'), [
'# Chief Spec',
'Status: ready',
'Title: Verify isolated credentials',
'Summary: Refuse ambient workspace credentials.',
'Recipe: single',
'Repos: AgentWorkforce/cloud',
].join('\n'))
const manifestPath = join(root, 'notion.json')
await writeFile(manifestPath, JSON.stringify({
version: 1,
mountRoot: './notion',
workerMountTransport: { kind: 'relay-channel' },
tasks: [{ page: '3b36800c1c90801db1cfc8f2e1cff7cf' }],
}))
const errors = buffer()

const code = await runFleetCli(['intake', 'notion', manifestPath], {
env: {},
createFleet: () => { throw new Error('missing key must fail before fleet construction') },
stdout: buffer(),
stderr: errors,
})

expect(code).toBe(1)
expect(errors.text()).toContain('requires an active Agent Relay workspace')
} finally {
await rm(root, { recursive: true, force: true })
}
Expand Down
81 changes: 65 additions & 16 deletions src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,13 @@ import {
import type { FactoryIntegrationProvider } from '../ports'
import { checkMountStaleness } from '../mount/relayfile-binary'
import { MountAuthScopeError } from '../mount/mount-auth-error'
import { resolveRelayWorkspaceKey } from '../fleet/relay-workspace-key'
import {
GhCliIssuePublisher,
RelayChannelNotionContractPublisher,
loadNotionIntakeManifest,
runNotionIntake,
type NotionContractPublisher,
type WorkspaceTaskDispatcher,
} from '../intake'

Expand Down Expand Up @@ -111,6 +114,8 @@ interface FleetCliDeps {
confirmIntegrationConnect?: (provider: FactoryIntegrationProvider) => Promise<boolean>
openIntegrationUrl?: (url: string) => void | Promise<void>
featureMapCheck?: (options?: CheckFeatureMapOptions) => Promise<FeatureMapCheckReport>
/** Hermetic portable Notion contract publisher for intake tests and alternate runtimes. */
notionContracts?: NotionContractPublisher
/** Hermetic verification-environment sweep for CLI tests and alternate runtimes. */
reapEnvironments?: typeof reapFactoryEnvironmentsOnce
}
Expand Down Expand Up @@ -151,6 +156,7 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom
let fleet: FleetClient | undefined
let mount: MountClient | undefined
let reporter: FactoryEventReporter | undefined
let notionContracts: NotionContractPublisher | undefined

try {
if (argv.some(isHelpFlag)) {
Expand Down Expand Up @@ -180,6 +186,19 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom

if (command.kind === 'notion-intake') {
const manifest = await loadNotionIntakeManifest(command.manifestPath)
if (!globals.dryRun && manifest.workerMountTransport.kind === 'relay-channel') {
notionContracts = deps.notionContracts
if (!notionContracts) {
const workspaceKey = resolveRelayWorkspaceKey({
env: deps.env ?? process.env,
...(deps.env ? { activeWorkspaceKey: () => undefined } : {}),
})
if (!workspaceKey) {
throw new Error('relay-channel worker mount transport requires an active Agent Relay workspace')
}
notionContracts = new RelayChannelNotionContractPublisher({ workspaceKey })
}
}
const workspace: WorkspaceTaskDispatcher = {
dispatch: async (task) => {
fleet ??= await buildFleet(globals, undefined, deps)
Expand All @@ -196,11 +215,33 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom
fleet.preserveInfrastructureOnDispose?.()
return { agent: spawned.name, node: spawned.node, status: 'spawned' }
},
redispatch: async (task) => {
fleet ??= await buildFleet(globals, undefined, deps)
const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name)
if (running) {
await fleet.sendMessage({ to: `@${running.name}`, text: task.task, mode: 'steer' })
return { agent: running.name, node: running.node, status: 'updated-running' }
}
const spawned = await fleet.spawn({
name: task.name,
capability: 'spawn:codex',
node: task.node ?? 'self',
task: task.task,
cwd: task.projectPath,
invocationId: task.invocationId,
})
fleet.preserveInfrastructureOnDispose?.()
return { agent: spawned.name, node: spawned.node, status: 'respawned' }
},
}
const report = await runNotionIntake({
manifest,
dispatch: !globals.dryRun,
...(!globals.dryRun ? { github: new GhCliIssuePublisher(), workspace } : {}),
...(!globals.dryRun ? {
github: new GhCliIssuePublisher(),
workspace,
...(notionContracts ? { contracts: notionContracts } : {}),
} : {}),
})
writeJson(out, report)
return report.ok ? 0 : 1
Expand Down Expand Up @@ -406,24 +447,32 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom
return 1
} finally {
try {
await mount?.dispose?.()
try {
await notionContracts?.dispose?.()
} catch {
err.write('[factory] warning: Notion contract publisher failed during shutdown\n')
}
} finally {
try {
await fleet?.dispose()
await mount?.dispose?.()
} finally {
if (reporter) {
try {
await reporter.report(createFactoryCloudEventV1({
type: 'instance.stopping',
attributes: { component: 'cli', operation: 'stop' },
}))
await reporter.report(createFactoryCloudEventV1({
type: 'instance.stopped',
attributes: { component: 'cli', operation: 'stop' },
}))
await reporter.close?.({ deadlineMs: 2_000 })
} catch {
err.write('[factory] warning: Cloud progress reporter failed during shutdown\n')
try {
await fleet?.dispose()
} finally {
if (reporter) {
try {
await reporter.report(createFactoryCloudEventV1({
type: 'instance.stopping',
attributes: { component: 'cli', operation: 'stop' },
}))
await reporter.report(createFactoryCloudEventV1({
type: 'instance.stopped',
attributes: { component: 'cli', operation: 'stop' },
}))
await reporter.close?.({ deadlineMs: 2_000 })
} catch {
err.write('[factory] warning: Cloud progress reporter failed during shutdown\n')
}
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/intake/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ export {
type NotionIntakeReport,
type NotionIntakeResult,
type NotionIntakeTarget,
type NotionContractDelivery,
type NotionContractPublisher,
type NotionRecipe,
type NormalizedNotionTask,
type WorkspaceTaskDispatcher,
} from './notion'

export {
RelayChannelNotionContractPublisher,
contractChannelName,
contractMarkerPrefix,
} from './notion-relay-contract'
Loading