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 .agentworkforce/features/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ version: '1.1'
updated: '2026-08-17'
catalog:
category_count: 25
feature_count: 319
feature_count: 320
tier_counts:
1: 49
2: 127
3: 11
3: 12
4: 52
5: 65
6: 15
Expand Down Expand Up @@ -102,6 +102,14 @@ categories:
location: src/intake/notion.ts, src/cli/fleet.ts
verify_tier: 1

- id: cli-notion-manifest-generate
name: Factory Tasks Notion Manifest Generation
cli: factory intake notion generate
api: '@agent-relay/factory/intake generateFactoryTasksManifest()'
description: Query the Factory Tasks data source without writeback, map only Ready for Agent rows and complete page briefs into schema-validated intake bootstrap authorizations, and emit them in stable page-id order
location: src/intake/notion-manifest.ts, src/cli/fleet.ts
verify_tier: 3

- id: cli-notion-intake-dispatch
name: Mounted Notion Spec Dispatch
cli: factory intake notion <manifest>
Expand Down
22 changes: 22 additions & 0 deletions .agentworkforce/features/verify/procedures.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,28 @@ fleet fixture is ready. `start`, `kill-loop`, `reap-orphans`, `babysit`, and
where promised, progress logs stay on stderr, unknown commands/options fail, and
help/version/feature-map validation do not load config or construct providers.

For Factory Tasks manifest generation, first run the hermetic API-shape and
round-trip coverage in `src/intake/notion-manifest.test.ts`. The live tier-3
extension requires a read-only `NOTION_API_KEY` whose connection can access the
Factory Tasks parent database:

```bash
factory intake notion generate > "$TMP/notion-intake.json"
node --input-type=module - "$TMP/notion-intake.json" <<'NODE'
import { readFileSync } from 'node:fs'
import { manifestSchema } from './dist/intake/index.js'
const manifest = manifestSchema.parse(JSON.parse(readFileSync(process.argv[2], 'utf8')))
if (!manifest.tasks.every((task) => task.bootstrap?.status === 'ready')) process.exit(1)
NODE
```

Confirm every task matches a current `Ready for Agent` row, `Labels` and
`Route` are merged only into repository targets, `Public Summary` is mapped
only as the reviewed public-safe repository description, rerunning without
database changes produces byte-identical JSON, and the row statuses are
unchanged. Do not run non-dry intake against production rows as part of this
verification.

**Automation limit:** the package and fixture subset is deterministic. Commands
that signal a process or mutate an issue/PR remain live or manual checks.

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,31 @@ pages can be admitted only with a bootstrap entry containing the exact
`authorizedPageId`, destination, safe summary, and operator reason. That escape
hatch is deliberately page-specific; there is no title or content heuristic.

Factory can generate that manifest directly from the **Factory Tasks** Notion
data source. The generator uses `NOTION_API_KEY` for read-only data-source and
page-markdown requests, selects only rows whose `Status` is `Ready for Agent`,
and never updates a row or its status. `Labels` and `Route` values are combined
for repository issue labels. A repository row can supply a separately reviewed
`Public Summary`; Factory uses that text for a public lifecycle issue and never
copies the private page body. The output order is stable by page ID, so the same
database state produces the same manifest on every run.

```bash
factory intake notion generate \
--mount-root ../.integrations/notion \
--worker-mount-root .integrations/notion \
--worker-mount-transport relay-channel \
--state-path ../.factory/notion-intake-state.json \
> ./ops/notion-intake.json
```

`--data-source` can override the built-in Factory Tasks data source ID. Paths in
the generated manifest retain the existing intake semantics: `mountRoot` and
`statePath` are resolved relative to the saved manifest, while
`workerMountRoot` is the path workers receive. Generation fails closed if there
are no ready rows, a required property is missing, a row sets both (or neither)
of `Repo` and `Project Path`, or Notion returns a truncated page body.

```json
{
"version": 1,
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/dist-entrypoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ describe('published dist entrypoints', () => {
expect(main.KubernetesEnvironmentProvider).toBeTypeOf('function')
expect(hosted.createHostedFactory).toBeTypeOf('function')
expect(hosted.DurableObjectHostedFactoryStateStore).toBeTypeOf('function')
expect(intake.generateFactoryTasksManifest).toBeTypeOf('function')
expect(intake.NotionApiFactoryTasksClient).toBeTypeOf('function')
expect(intake.RelayChannelNotionClaimStore).toBeTypeOf('function')
expect(intake.runNotionIntake).toBeTypeOf('function')
expect(environments.KubernetesEnvironmentProvider).toBeTypeOf('function')
Expand Down
15 changes: 15 additions & 0 deletions src/cli/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,19 @@ describe('fleet CLI parsing', () => {
expect(() => parseFleetCommand(['intake', 'notion'])).toThrow(
'requires a manifest path',
)
expect(parseFleetCommand([
'intake',
'notion',
'generate',
'--data-source',
'collection://a7fb83ad-c667-4003-a1dc-132c6826aac1',
'--worker-mount-transport',
'relay-channel',
])).toEqual({
kind: 'notion-manifest',
dataSourceId: 'collection://a7fb83ad-c667-4003-a1dc-132c6826aac1',
workerMountTransport: 'relay-channel',
})
})

it('parses global backend, config, and dry-run independently of subcommand position', () => {
Expand Down Expand Up @@ -758,6 +771,8 @@ describe('fleet CLI runtime', () => {
const durableClaims = new Map<string, { sourceKey: string; digest: string; claimedAt: string }>()
const notionClaims = {
get: vi.fn(async (sourceKey: string) => durableClaims.get(sourceKey)),
findBySourcePrefix: vi.fn(async (sourceKeyPrefix: string) => [...durableClaims.values()]
.filter((claim) => claim.sourceKey.startsWith(sourceKeyPrefix))),
claim: vi.fn(async (claim: { sourceKey: string; digest: string; claimedAt: string }) => {
const existing = durableClaims.get(claim.sourceKey)
if (existing) return { status: 'existing' as const, claim: existing }
Expand Down
58 changes: 58 additions & 0 deletions src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,13 @@ import {
} from '../version-info'
import {
GhCliIssuePublisher,
NotionApiFactoryTasksClient,
RelayChannelNotionClaimStore,
RelayChannelNotionContractPublisher,
generateFactoryTasksManifest,
loadNotionIntakeManifest,
runNotionIntake,
type FactoryTasksNotionClient,
type NotionIntakeClaimStore,
type NotionContractPublisher,
type WorkspaceTaskDispatcher,
Expand Down Expand Up @@ -152,6 +155,8 @@ export interface FleetCliDeps {
notionContracts?: NotionContractPublisher
/** Hermetic workspace-global Notion claim store for tests and alternate runtimes. */
notionClaims?: NotionIntakeClaimStore
/** Hermetic Factory Tasks reader for manifest-generation tests and alternate runtimes. */
notionFactoryTasks?: FactoryTasksNotionClient
/** Hermetic verification-environment sweep for CLI tests and alternate runtimes. */
reapEnvironments?: typeof reapFactoryEnvironmentsOnce
/** Hermetic package/registry metadata for CLI tests and alternate runtimes. */
Expand Down Expand Up @@ -187,6 +192,14 @@ type ParsedCommand =
| { kind: 'featuremap-check'; manifestPath?: string; baseRef?: string }
| { kind: 'factory-init'; repo?: string; workspaceId?: string }
| { kind: 'notion-intake'; manifestPath: string }
| {
kind: 'notion-manifest'
dataSourceId?: string
mountRoot?: string
workerMountRoot?: string
workerMountTransport?: 'local' | 'relay-channel'
statePath?: string
}

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

if (command.kind === 'notion-manifest') {
const notion = deps.notionFactoryTasks ?? new NotionApiFactoryTasksClient({
token: (deps.env ?? process.env).NOTION_API_KEY ?? '',
})
const manifest = await generateFactoryTasksManifest({
client: notion,
...(command.dataSourceId ? { dataSourceId: command.dataSourceId } : {}),
...(command.mountRoot ? { mountRoot: command.mountRoot } : {}),
...(command.workerMountRoot ? { workerMountRoot: command.workerMountRoot } : {}),
...(command.workerMountTransport
? { workerMountTransport: command.workerMountTransport }
: {}),
...(command.statePath ? { statePath: command.statePath } : {}),
})
writeJson(out, manifest)
return 0
}

if (command.kind === 'notion-intake') {
const manifest = await loadNotionIntakeManifest(command.manifestPath)
if (!globals.dryRun) {
Expand Down Expand Up @@ -593,6 +624,31 @@ export function parseFleetCommand(args: string[]): ParsedCommand {
function parseIntakeCommand(args: string[]): ParsedCommand {
const [source, manifestPath, ...rest] = args
if (source !== 'notion') throw new Error('factory intake currently requires the notion source')
if (manifestPath === 'generate') {
const parsed = parseFlags(rest)
const allowed = new Set([
'data-source',
'mount-root',
'worker-mount-root',
'worker-mount-transport',
'state-path',
])
const unexpected = Object.keys(parsed).find((key) => !allowed.has(key))
if (unexpected) throw new Error(`Unknown Factory Tasks manifest option: --${unexpected}`)
const workerMountTransport = parsed['worker-mount-transport']
if (workerMountTransport !== undefined &&
workerMountTransport !== 'local' && workerMountTransport !== 'relay-channel') {
throw new Error('--worker-mount-transport must be local or relay-channel')
}
return {
kind: 'notion-manifest',
...(parsed['data-source'] ? { dataSourceId: parsed['data-source'] } : {}),
...(parsed['mount-root'] ? { mountRoot: parsed['mount-root'] } : {}),
...(parsed['worker-mount-root'] ? { workerMountRoot: parsed['worker-mount-root'] } : {}),
...(workerMountTransport ? { workerMountTransport } : {}),
...(parsed['state-path'] ? { statePath: parsed['state-path'] } : {}),
}
}
if (!manifestPath) throw new Error('factory intake notion requires a manifest path')
if (rest.length > 0) throw new Error(`Unexpected factory intake argument: ${rest[0]}`)
return { kind: 'notion-intake', manifestPath }
Expand Down Expand Up @@ -2500,6 +2556,8 @@ Commands:
close-probe <PR> Probe/close a PR for an issue
featuremap check Validate .agentworkforce/features/manifest.yaml
intake notion <file> Normalize mounted Notion specs into Factory work
intake notion generate
Emit a manifest for Ready for Agent Factory Tasks
fleet <command> Low-level fleet commands: spawn, roster, release

Options:
Expand Down
15 changes: 14 additions & 1 deletion src/dispatch/work-unit-identity.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'

import { dispatchAgentIdentityKey, dispatchIssueIdentity } from './work-unit-identity'
import {
dispatchAgentIdentityKey,
dispatchIssueIdentity,
dispatchNotionPageIdentity,
} from './work-unit-identity'

describe('dispatch work-unit identity', () => {
it('uses one provider-native identity for GitHub Relayfile aliases', () => {
Expand Down Expand Up @@ -62,4 +66,13 @@ describe('dispatch work-unit identity', () => {
const issue = { uuid: ' ', key: 'opaque-key', path: '/some/other/mount/opaque-key.json' }
expect(() => dispatchIssueIdentity(issue)).toThrow(/provider identity is empty/u)
})

it('uses the provider-native Notion page id without a destination alias', () => {
expect(dispatchNotionPageIdentity('3B36800C-1C90-801D-B1CF-C8F2E1CFF7CF')).toBe(
'notion:3b36800c-1c90-801d-b1cf-c8f2e1cff7cf',
)
expect(() => dispatchNotionPageIdentity('notion-page:repo:mutable/destination')).toThrow(
/canonical page id/u,
)
})
})
13 changes: 13 additions & 0 deletions src/dispatch/work-unit-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ISSUE_KEY_PARTS } from '../issue-key-match'
import { githubLifecycleIdentity } from '../state/github-lifecycle-identity'

const DISPATCH_IDENTITY_VERSION = 'factory:dispatch:v1'
const NOTION_PAGE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u

/**
* Provider-native identity for one issue, independent of whichever Relayfile
Expand All @@ -21,6 +22,18 @@ export function dispatchIssueIdentity(issue: IssueRef): string {
return `${ISSUE_KEY_PARTS.test(issue.key) ? 'linear' : 'issue'}:${uuid}`
}

/**
* Provider-native identity for one Notion page, independent of the mutable
* repository, workspace path, or mount surface through which it is offered.
*/
export function dispatchNotionPageIdentity(pageId: string): string {
const canonical = pageId.trim().toLowerCase()
if (!NOTION_PAGE_ID.test(canonical)) {
throw new Error('Cannot derive dispatch identity for Notion page: provider identity is not a canonical page id')
}
return `notion:${canonical}`
}

/**
* Stable broker reclaim proof for one issue role. Retries of the same work
* unit reproduce it; a same-looking issue from another provider/repository
Expand Down
14 changes: 14 additions & 0 deletions src/intake/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export {
GhCliIssuePublisher,
loadNotionIntakeManifest,
manifestSchema,
normalizeNotionManifest,
normalizeNotionPageId,
notionRecipeSchema,
parseChiefSpecHeader,
runNotionIntake,
type GithubIssuePublisher,
Expand All @@ -20,6 +22,18 @@ export {
type WorkspaceTaskDispatcher,
} from './notion'

export {
FACTORY_TASKS_DATA_SOURCE_ID,
NOTION_API_VERSION,
READY_FOR_AGENT_STATUS,
NotionApiFactoryTasksClient,
generateFactoryTasksManifest,
type FactoryTasksNotionClient,
type FactoryTasksNotionPage,
type GenerateFactoryTasksManifestOptions,
type NotionApiFactoryTasksClientOptions,
} from './notion-manifest'

export {
RelayChannelNotionClaimStore,
notionClaimChannelName,
Expand Down
Loading