diff --git a/.agentworkforce/features/manifest.yaml b/.agentworkforce/features/manifest.yaml index 3e75912..1e8ee08 100644 --- a/.agentworkforce/features/manifest.yaml +++ b/.agentworkforce/features/manifest.yaml @@ -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 @@ -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 diff --git a/.agentworkforce/features/verify/procedures.md b/.agentworkforce/features/verify/procedures.md index 1ad4505..346494f 100644 --- a/.agentworkforce/features/verify/procedures.md +++ b/.agentworkforce/features/verify/procedures.md @@ -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. diff --git a/README.md b/README.md index 417a2ee..a7269f7 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/__tests__/dist-entrypoints.test.ts b/src/__tests__/dist-entrypoints.test.ts index 61ddd1d..f86b731 100644 --- a/src/__tests__/dist-entrypoints.test.ts +++ b/src/__tests__/dist-entrypoints.test.ts @@ -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') diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index c35073b..cfb2d8a 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -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', () => { @@ -758,6 +771,8 @@ describe('fleet CLI runtime', () => { const durableClaims = new Map() 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 } diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index d8703ae..91759f1 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -94,10 +94,13 @@ import { } from '../version-info' import { GhCliIssuePublisher, + NotionApiFactoryTasksClient, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, + generateFactoryTasksManifest, loadNotionIntakeManifest, runNotionIntake, + type FactoryTasksNotionClient, type NotionIntakeClaimStore, type NotionContractPublisher, type WorkspaceTaskDispatcher, @@ -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. */ @@ -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 { const out = deps.stdout ?? process.stdout @@ -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) { @@ -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 } @@ -2500,6 +2556,8 @@ Commands: close-probe Probe/close a PR for an issue featuremap check Validate .agentworkforce/features/manifest.yaml intake notion Normalize mounted Notion specs into Factory work + intake notion generate + Emit a manifest for Ready for Agent Factory Tasks fleet Low-level fleet commands: spawn, roster, release Options: diff --git a/src/dispatch/work-unit-identity.test.ts b/src/dispatch/work-unit-identity.test.ts index db52910..7c837e2 100644 --- a/src/dispatch/work-unit-identity.test.ts +++ b/src/dispatch/work-unit-identity.test.ts @@ -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', () => { @@ -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, + ) + }) }) diff --git a/src/dispatch/work-unit-identity.ts b/src/dispatch/work-unit-identity.ts index b5c88a6..03dadbf 100644 --- a/src/dispatch/work-unit-identity.ts +++ b/src/dispatch/work-unit-identity.ts @@ -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 @@ -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 diff --git a/src/intake/index.ts b/src/intake/index.ts index 1da4e62..f0e86bc 100644 --- a/src/intake/index.ts +++ b/src/intake/index.ts @@ -1,8 +1,10 @@ export { GhCliIssuePublisher, loadNotionIntakeManifest, + manifestSchema, normalizeNotionManifest, normalizeNotionPageId, + notionRecipeSchema, parseChiefSpecHeader, runNotionIntake, type GithubIssuePublisher, @@ -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, diff --git a/src/intake/notion-manifest.test.ts b/src/intake/notion-manifest.test.ts new file mode 100644 index 0000000..44475b3 --- /dev/null +++ b/src/intake/notion-manifest.test.ts @@ -0,0 +1,510 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { runFleetCli } from '../cli/fleet' +import { + manifestSchema, + runNotionIntake, + type GithubIssuePublisher, + type NotionIntakeClaimStore, + type WorkspaceTaskDispatcher, +} from './notion' +import { + FACTORY_TASKS_DATA_SOURCE_ID, + NotionApiFactoryTasksClient, + generateFactoryTasksManifest, + type FactoryTasksNotionClient, + type FactoryTasksNotionPage, +} from './notion-manifest' + +const repoPageId = '11111111-1111-4111-8111-111111111111' +const workspacePageId = '22222222-2222-4222-8222-222222222222' +const draftPageId = '33333333-3333-4333-8333-333333333333' +const roots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Factory Tasks Notion manifest generator', () => { + it('maps only ready repo and workspace rows and round-trips idempotently through intake', async () => { + const bodies = new Map([ + [repoPageId, '# Repository brief\n\nImplement the reviewed repository change.'], + [workspacePageId, '# Workspace brief\n\nRun the reviewed local benchmark.'], + [draftPageId, '# Draft brief\n\nThis must not dispatch.'], + ]) + const client = fakeNotion([ + factoryTaskRow({ + id: workspacePageId, + title: 'Run local benchmark', + reason: 'Operator authorized the exact workspace target.', + projectPath: '/work/benchmarks', + node: 'benchmark-host', + }), + factoryTaskRow({ + id: draftPageId, + status: 'Draft', + title: 'Unreviewed task', + reason: 'This row is not ready.', + repo: 'AgentWorkforce/factory', + }), + factoryTaskRow({ + id: repoPageId, + title: 'Implement repository change', + reason: 'Operator authorized the exact repository target.', + recipe: 'team', + repo: 'AgentWorkforce/factory', + publicSummary: 'Implement the reviewed repository change without exposing the private brief.', + labels: ['intake', 'shared'], + routes: ['implementer', 'shared'], + }), + ], bodies) + + const root = await mkdtemp(join(tmpdir(), 'factory-tasks-manifest-')) + roots.push(root) + for (const [pageId, body] of bodies) { + const pageRoot = join(root, 'mount', 'pages', pageId) + await mkdir(pageRoot, { recursive: true }) + await writeFile(join(pageRoot, 'content.md'), body) + } + + const manifest = await generateFactoryTasksManifest({ + client, + mountRoot: join(root, 'mount'), + statePath: join(root, 'state.json'), + }) + + expect(manifestSchema.parse(manifest)).toEqual(manifest) + expect(manifest.tasks).toEqual([ + expect.objectContaining({ + page: repoPageId, + bootstrap: expect.objectContaining({ + authorizedPageId: repoPageId, + status: 'ready', + title: 'Implement repository change', + recipe: 'team', + summary: bodies.get(repoPageId), + targets: [{ + repo: 'AgentWorkforce/factory', + labels: ['intake', 'shared', 'implementer'], + publicSummary: 'Implement the reviewed repository change without exposing the private brief.', + }], + }), + }), + expect.objectContaining({ + page: workspacePageId, + bootstrap: expect.objectContaining({ + authorizedPageId: workspacePageId, + title: 'Run local benchmark', + recipe: 'single', + summary: bodies.get(workspacePageId), + targets: [{ projectPath: '/work/benchmarks', node: 'benchmark-host' }], + }), + }), + ]) + expect(client.retrievePageMarkdown).not.toHaveBeenCalledWith(draftPageId) + + const claims = memoryClaims() + const github = memoryGithub() + const workspace: WorkspaceTaskDispatcher = { + dispatch: vi.fn(async (task) => ({ agent: task.name, node: task.node, status: 'spawned' })), + } + + const first = await runNotionIntake({ manifest, dispatch: true, claims, github, workspace }) + const second = await runNotionIntake({ manifest, dispatch: true, claims, github, workspace }) + + expect(first).toMatchObject({ + ok: true, + results: [ + { status: 'dispatched', target: { repo: 'AgentWorkforce/factory' } }, + { status: 'dispatched', target: { projectPath: '/work/benchmarks' } }, + ], + }) + expect(second).toMatchObject({ + ok: true, + results: [ + { status: 'already-dispatched', target: { repo: 'AgentWorkforce/factory' } }, + { status: 'already-dispatched', target: { projectPath: '/work/benchmarks' } }, + ], + }) + expect(github.createIssue).toHaveBeenCalledOnce() + expect(github.createIssue).toHaveBeenCalledWith(expect.objectContaining({ + labels: ['factory-ready', 'agent:team', 'intake', 'shared', 'implementer'], + body: expect.stringContaining('Implement the reviewed repository change without exposing the private brief.'), + })) + expect(vi.mocked(github.createIssue).mock.calls[0]![0].body).not.toContain( + 'Implement the reviewed repository change.', + ) + expect(workspace.dispatch).toHaveBeenCalledOnce() + }) + + it('does not create a second issue when a Ready page changes repository', async () => { + const body = '# Private brief\n\nInternal implementation details.' + const root = await mkdtemp(join(tmpdir(), 'factory-tasks-identity-')) + roots.push(root) + const pageRoot = join(root, 'mount', 'pages', repoPageId) + await mkdir(pageRoot, { recursive: true }) + await writeFile(join(pageRoot, 'content.md'), body) + const claims = memoryClaims() + const github = memoryGithub() + + const firstManifest = await generateFactoryTasksManifest({ + client: fakeNotion([factoryTaskRow({ + id: repoPageId, + title: 'Stable work unit', + reason: 'Operator authorized the task.', + repo: 'Example/one', + publicSummary: 'Apply the reviewed public change.', + })], new Map([[repoPageId, body]])), + mountRoot: join(root, 'mount'), + statePath: join(root, 'first-state.json'), + }) + const first = await runNotionIntake({ + manifest: firstManifest, + dispatch: true, + claims, + github, + }) + + const editedManifest = await generateFactoryTasksManifest({ + client: fakeNotion([factoryTaskRow({ + id: repoPageId, + title: 'Stable work unit', + reason: 'Operator authorized the task.', + repo: 'Example/two', + publicSummary: 'Apply the reviewed public change.', + })], new Map([[repoPageId, body]])), + mountRoot: join(root, 'mount'), + statePath: join(root, 'independent-state.json'), + }) + const edited = await runNotionIntake({ + manifest: editedManifest, + dispatch: true, + claims, + github, + }) + + expect(first.results).toEqual([expect.objectContaining({ status: 'dispatched' })]) + expect(edited).toMatchObject({ + ok: false, + results: [{ + status: 'blocked', + target: { repo: 'Example/two' }, + reason: 'durable Notion claim digest does not match the mounted spec', + }], + }) + expect(github.createIssue).toHaveBeenCalledOnce() + expect(claims.stored.has(`notion:${repoPageId}`)).toBe(true) + expect(claims.stored.has(`notion:${repoPageId}:repo:example/two`)).toBe(false) + }) + + it('keeps distinct pages separate while one page fans out to distinct targets', async () => { + const bodies = new Map([ + [repoPageId, 'Private first-page execution contract.'], + [workspacePageId, 'Private second-page execution contract.'], + ]) + const root = await mkdtemp(join(tmpdir(), 'factory-tasks-fanout-')) + roots.push(root) + for (const [pageId, body] of bodies) { + const pageRoot = join(root, 'mount', 'pages', pageId) + await mkdir(pageRoot, { recursive: true }) + await writeFile(join(pageRoot, 'content.md'), body) + } + const manifest = await generateFactoryTasksManifest({ + client: fakeNotion([ + factoryTaskRow({ + id: repoPageId, + title: 'Fan out one page', + reason: 'Operator authorized both public targets.', + repo: 'Example/one', + publicSummary: 'Apply the first reviewed public change.', + }), + factoryTaskRow({ + id: workspacePageId, + title: 'Dispatch a distinct page', + reason: 'Operator authorized the distinct public target.', + repo: 'Example/two', + publicSummary: 'Apply the second reviewed public change.', + }), + ], bodies), + mountRoot: join(root, 'mount'), + statePath: join(root, 'state.json'), + }) + manifest.tasks[0]!.bootstrap!.targets.push({ + repo: 'Example/three', + labels: [], + publicSummary: 'Apply the additional reviewed public change.', + }) + const claims = memoryClaims() + const github = memoryGithub() + + const report = await runNotionIntake({ manifest, dispatch: true, claims, github }) + + expect(report.ok).toBe(true) + expect(report.results).toHaveLength(3) + expect(report.results.every((result) => result.status === 'dispatched')).toBe(true) + expect(github.createIssue).toHaveBeenCalledTimes(3) + expect([...claims.stored.keys()].filter((key) => + key === `notion:${repoPageId}` || key === `notion:${workspacePageId}`, + ).sort()).toEqual([ + `notion:${repoPageId}`, + `notion:${workspacePageId}`, + ]) + }) + + it('emits the generated manifest through the Factory CLI without constructing a fleet', async () => { + const client = fakeNotion([ + factoryTaskRow({ + id: repoPageId, + title: 'CLI manifest task', + reason: 'Ready row explicitly authorized by the operator.', + repo: 'AgentWorkforce/factory', + }), + ], new Map([[repoPageId, 'CLI brief']])) + const output = buffer() + + const code = await runFleetCli([ + 'intake', + 'notion', + 'generate', + '--mount-root', + '../notion', + '--worker-mount-transport', + 'relay-channel', + ], { + notionFactoryTasks: client, + createFleet: () => { throw new Error('manifest generation must not construct a fleet') }, + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toMatchObject({ + version: 1, + mountRoot: '../notion', + workerMountTransport: { kind: 'relay-channel' }, + tasks: [{ page: repoPageId, bootstrap: { status: 'ready' } }], + }) + }) + + it('uses the live data-source schema for server filtering and paginates every result', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + object: 'data_source', + properties: { Status: { id: 'status', type: 'status', status: {} } }, + })) + .mockResolvedValueOnce(jsonResponse({ + object: 'list', + results: [factoryTaskRow({ + id: repoPageId, + title: 'First task', + reason: 'First reason', + repo: 'AgentWorkforce/factory', + })], + has_more: true, + next_cursor: 'next-page', + })) + .mockResolvedValueOnce(jsonResponse({ + object: 'list', + results: [factoryTaskRow({ + id: workspacePageId, + title: 'Second task', + reason: 'Second reason', + projectPath: '/work/factory', + })], + has_more: false, + next_cursor: null, + })) + const client = new NotionApiFactoryTasksClient({ token: 'test-token', fetch }) + + const rows = await client.queryReadyTasks(`collection://${FACTORY_TASKS_DATA_SOURCE_ID}`) + + expect(rows.map((row) => row.id)).toEqual([repoPageId, workspacePageId]) + const firstQuery = JSON.parse(fetch.mock.calls[1]![1]!.body as string) + const secondQuery = JSON.parse(fetch.mock.calls[2]![1]!.body as string) + expect(firstQuery).toMatchObject({ + filter: { property: 'Status', status: { equals: 'Ready for Agent' } }, + result_type: 'page', + page_size: 100, + }) + expect(secondQuery.start_cursor).toBe('next-page') + expect(String(fetch.mock.calls[1]![0])).toContain('filter_properties%5B%5D=Task') + expect(String(fetch.mock.calls[1]![0])).toContain('filter_properties%5B%5D=Public+Summary') + expect(fetch.mock.calls.map((call) => call[1]?.method ?? 'GET')).toEqual([ + 'GET', + 'POST', + 'POST', + ]) + }) + + it('fails closed when a ready row does not select exactly one destination', async () => { + const client = fakeNotion([ + factoryTaskRow({ + id: repoPageId, + title: 'Ambiguous task', + reason: 'Ambiguous target must not dispatch.', + repo: 'AgentWorkforce/factory', + projectPath: '/work/factory', + }), + ], new Map([[repoPageId, 'Ambiguous brief']])) + + await expect(generateFactoryTasksManifest({ client })).rejects.toThrow( + 'must set exactly one of Repo or Project Path', + ) + }) + + it('rejects incomplete markdown and markdown returned for another provider page', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + object: 'page_markdown', + id: repoPageId, + markdown: 'partial brief', + truncated: true, + unknown_block_ids: [], + })) + .mockResolvedValueOnce(jsonResponse({ + object: 'page_markdown', + id: workspacePageId, + markdown: 'wrong page brief', + truncated: false, + unknown_block_ids: [], + })) + const client = new NotionApiFactoryTasksClient({ token: 'test-token', fetch }) + + await expect(client.retrievePageMarkdown(repoPageId)).rejects.toThrow('complete execution brief') + await expect(client.retrievePageMarkdown(repoPageId)).rejects.toThrow('did not match requested page') + }) + + it('rejects duplicate provider rows before authorizing either copy', async () => { + const row = factoryTaskRow({ + id: repoPageId, + title: 'Duplicate provider row', + reason: 'The provider identity must be unique.', + repo: 'AgentWorkforce/factory', + publicSummary: 'Apply the reviewed public change.', + }) + const client = fakeNotion([row, structuredClone(row)], new Map([[repoPageId, 'Private brief']])) + + await expect(generateFactoryTasksManifest({ client })).rejects.toThrow( + `Notion returned duplicate Factory Tasks row ${repoPageId}`, + ) + expect(client.retrievePageMarkdown).toHaveBeenCalledOnce() + }) +}) + +function factoryTaskRow(input: { + id: string + status?: string + title: string + reason: string + recipe?: 'single' | 'workflow' | 'team' + repo?: string + publicSummary?: string + labels?: string[] + routes?: string[] + projectPath?: string + node?: string +}): FactoryTasksNotionPage { + return { + object: 'page', + id: input.id, + properties: { + Status: selectProperty('status', input.status ?? 'Ready for Agent'), + Task: richTextProperty('title', input.title), + Reason: richTextProperty('rich_text', input.reason), + Recipe: selectProperty('select', input.recipe ?? 'single'), + Repo: richTextProperty('rich_text', input.repo), + 'Public Summary': richTextProperty('rich_text', input.publicSummary), + Labels: multiSelectProperty(input.labels ?? []), + Route: multiSelectProperty(input.routes ?? []), + 'Project Path': richTextProperty('rich_text', input.projectPath), + Node: richTextProperty('rich_text', input.node), + }, + } +} + +function richTextProperty(type: 'title' | 'rich_text', value?: string): Record { + return { type, [type]: value ? [{ plain_text: value }] : [] } +} + +function selectProperty(type: 'select' | 'status', value: string): Record { + return { type, [type]: { name: value } } +} + +function multiSelectProperty(values: string[]): Record { + return { type: 'multi_select', multi_select: values.map((name) => ({ name })) } +} + +function fakeNotion( + rows: FactoryTasksNotionPage[], + bodies: ReadonlyMap, +): FactoryTasksNotionClient & { + queryReadyTasks: ReturnType> + retrievePageMarkdown: ReturnType> +} { + return { + queryReadyTasks: vi.fn(async () => rows), + retrievePageMarkdown: vi.fn(async (pageId) => { + const body = bodies.get(pageId) + if (body === undefined) throw new Error(`missing fake body for ${pageId}`) + return body + }), + } +} + +function memoryClaims(): NotionIntakeClaimStore & { + stored: Map +} { + const stored = new Map() + return { + stored, + get: vi.fn(async (sourceKey) => stored.get(sourceKey)), + findBySourcePrefix: vi.fn(async (sourceKeyPrefix) => [...stored.values()] + .filter((claim) => claim.sourceKey.startsWith(sourceKeyPrefix))), + claim: vi.fn(async (claim) => { + const existing = stored.get(claim.sourceKey) + if (existing) return { status: 'existing' as const, claim: existing } + stored.set(claim.sourceKey, claim) + return { status: 'claimed' as const, claim } + }), + } +} + +function memoryGithub(): GithubIssuePublisher & { createIssue: ReturnType } { + const issues = new Map() + return { + repositoryVisibility: vi.fn(async () => 'public' as const), + missingLabels: vi.fn(async () => []), + findBySource: vi.fn(async (_repo, sourceKey) => issues.get(sourceKey)), + createIssue: vi.fn(async ({ body }) => { + const sourceKey = //u.exec(body)?.[1] + if (!sourceKey) throw new Error('fake issue is missing its source marker') + const issue = { number: 42, url: 'https://github.test/issues/42', body } + issues.set(sourceKey, issue) + return issue + }), + updateIssue: vi.fn(async () => undefined), + } +} + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +function buffer(): Pick & { text(): string } { + let value = '' + return { + write(chunk) { + value += String(chunk) + return true + }, + text: () => value, + } +} diff --git a/src/intake/notion-manifest.ts b/src/intake/notion-manifest.ts new file mode 100644 index 0000000..8c4b736 --- /dev/null +++ b/src/intake/notion-manifest.ts @@ -0,0 +1,330 @@ +import { z } from 'zod' + +import { + manifestSchema, + normalizeNotionPageId, + notionRecipeSchema, + type NotionIntakeManifest, + type NotionIntakeTarget, +} from './notion' + +export const FACTORY_TASKS_DATA_SOURCE_ID = 'a7fb83ad-c667-4003-a1dc-132c6826aac1' +export const NOTION_API_VERSION = '2026-03-11' +export const READY_FOR_AGENT_STATUS = 'Ready for Agent' + +const FACTORY_TASK_PROPERTIES = [ + 'Status', + 'Task', + 'Recipe', + 'Reason', + 'Repo', + 'Public Summary', + 'Labels', + 'Route', + 'Project Path', + 'Node', +] as const + +const notionPageSchema = z.object({ + object: z.literal('page'), + id: z.string().min(1), + properties: z.record(z.string(), z.unknown()), +}).passthrough() + +const notionQuerySchema = z.object({ + object: z.literal('list'), + results: z.array(notionPageSchema), + has_more: z.boolean(), + next_cursor: z.string().min(1).nullable(), +}).passthrough() + +const notionDataSourceSchema = z.object({ + object: z.literal('data_source'), + properties: z.record(z.string(), z.object({ type: z.string().min(1) }).passthrough()), +}).passthrough() + +const notionMarkdownSchema = z.object({ + object: z.literal('page_markdown'), + id: z.string().min(1), + markdown: z.string(), + truncated: z.boolean(), + unknown_block_ids: z.array(z.string()), +}).passthrough() + +export type FactoryTasksNotionPage = z.infer + +/** The minimal read-only Notion surface needed by the manifest generator. */ +export interface FactoryTasksNotionClient { + queryReadyTasks(dataSourceId: string): Promise + retrievePageMarkdown(pageId: string): Promise +} + +export interface NotionApiFactoryTasksClientOptions { + token: string + apiBaseUrl?: string + fetch?: typeof globalThis.fetch +} + +/** Queries Factory Tasks through Notion's versioned, read-only data APIs. */ +export class NotionApiFactoryTasksClient implements FactoryTasksNotionClient { + readonly #token: string + readonly #apiBaseUrl: string + readonly #fetch: typeof globalThis.fetch + + constructor(options: NotionApiFactoryTasksClientOptions) { + const token = options.token.trim() + if (!token) throw new Error('Notion manifest generation requires NOTION_API_KEY') + this.#token = token + this.#apiBaseUrl = (options.apiBaseUrl ?? 'https://api.notion.com').replace(/\/$/u, '') + this.#fetch = options.fetch ?? globalThis.fetch + } + + async queryReadyTasks(dataSourceId: string): Promise { + const id = normalizeDataSourceId(dataSourceId) + const descriptor = notionDataSourceSchema.parse( + await this.#request(`/v1/data_sources/${encodeURIComponent(id)}`), + ) + const statusType = descriptor.properties.Status?.type + if (statusType !== 'status' && statusType !== 'select') { + throw new Error( + `Factory Tasks property Status must be a Notion status or select, received ${statusType ?? 'missing'}`, + ) + } + + const query = new URLSearchParams() + for (const property of FACTORY_TASK_PROPERTIES) query.append('filter_properties[]', property) + const path = `/v1/data_sources/${encodeURIComponent(id)}/query?${query.toString()}` + const pages: FactoryTasksNotionPage[] = [] + const seenCursors = new Set() + let startCursor: string | undefined + + do { + if (startCursor) { + if (seenCursors.has(startCursor)) { + throw new Error('Notion Factory Tasks pagination repeated its next cursor') + } + seenCursors.add(startCursor) + } + const response = notionQuerySchema.parse(await this.#request(path, { + method: 'POST', + body: JSON.stringify({ + filter: { + property: 'Status', + [statusType]: { equals: READY_FOR_AGENT_STATUS }, + }, + sorts: [{ timestamp: 'created_time', direction: 'ascending' }], + result_type: 'page', + page_size: 100, + ...(startCursor ? { start_cursor: startCursor } : {}), + }), + })) + pages.push(...response.results) + startCursor = response.has_more ? response.next_cursor ?? undefined : undefined + if (response.has_more && !startCursor) { + throw new Error('Notion Factory Tasks pagination did not return its next cursor') + } + } while (startCursor) + + return pages + } + + async retrievePageMarkdown(pageId: string): Promise { + const id = normalizeNotionPageId(pageId) + const response = notionMarkdownSchema.parse( + await this.#request(`/v1/pages/${encodeURIComponent(id)}/markdown`), + ) + if (normalizeNotionPageId(response.id) !== id) { + throw new Error(`Notion markdown response did not match requested page ${id}`) + } + if (response.truncated || response.unknown_block_ids.length > 0) { + throw new Error(`Notion page ${id} could not be read as a complete execution brief`) + } + return response.markdown + } + + async #request(path: string, init: RequestInit = {}): Promise { + const response = await this.#fetch(`${this.#apiBaseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${this.#token}`, + 'Notion-Version': NOTION_API_VERSION, + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + }, + signal: AbortSignal.timeout(30_000), + }) + if (!response.ok) { + const details = (await response.text()).trim().slice(0, 2_000) + throw new Error( + `Notion API ${init.method ?? 'GET'} ${path.split('?')[0]} failed (${response.status})${details ? `: ${details}` : ''}`, + ) + } + return await response.json() + } +} + +export interface GenerateFactoryTasksManifestOptions { + client: FactoryTasksNotionClient + dataSourceId?: string + mountRoot?: string + workerMountRoot?: string + workerMountTransport?: 'local' | 'relay-channel' + statePath?: string +} + +/** Convert every currently-ready Factory Tasks row into an intake bootstrap authorization. */ +export async function generateFactoryTasksManifest( + options: GenerateFactoryTasksManifestOptions, +): Promise { + const rows = await options.client.queryReadyTasks( + options.dataSourceId ?? FACTORY_TASKS_DATA_SOURCE_ID, + ) + const readyRows = rows + .filter((row) => propertyText(row, 'Status', false) === READY_FOR_AGENT_STATUS) + .map((row) => ({ row, pageId: normalizeNotionPageId(row.id) })) + .sort((left, right) => left.pageId.localeCompare(right.pageId)) + + const seen = new Set() + const tasks: NotionIntakeManifest['tasks'] = [] + for (const { row, pageId } of readyRows) { + if (seen.has(pageId)) throw new Error(`Notion returned duplicate Factory Tasks row ${pageId}`) + seen.add(pageId) + + const repo = propertyText(row, 'Repo', false) + const projectPath = propertyText(row, 'Project Path', false) + if (Boolean(repo) === Boolean(projectPath)) { + throw new Error( + `Factory Tasks row ${pageId} must set exactly one of Repo or Project Path`, + ) + } + + let target: NotionIntakeTarget + if (repo) { + const publicSummary = propertyText(row, 'Public Summary', false) + const labels = unique([ + ...propertyList(row, 'Labels'), + ...propertyList(row, 'Route'), + ]) + target = { repo, labels, ...(publicSummary ? { publicSummary } : {}) } + } else { + const node = propertyText(row, 'Node', false) + target = { projectPath: projectPath!, ...(node ? { node } : {}) } + } + + const summary = (await options.client.retrievePageMarkdown(pageId)).trim() + tasks.push({ + page: pageId, + bootstrap: { + authorizedPageId: pageId, + reason: propertyText(row, 'Reason'), + status: 'ready', + title: propertyText(row, 'Task'), + recipe: notionRecipeSchema.parse(propertyText(row, 'Recipe').toLowerCase()), + summary, + targets: [target], + }, + }) + } + + if (tasks.length === 0) { + throw new Error(`Factory Tasks has no rows with Status = ${READY_FOR_AGENT_STATUS}`) + } + + return manifestSchema.parse({ + version: 1, + ...(options.mountRoot ? { mountRoot: options.mountRoot } : {}), + ...(options.workerMountRoot ? { workerMountRoot: options.workerMountRoot } : {}), + ...(options.workerMountTransport + ? { workerMountTransport: { kind: options.workerMountTransport } } + : {}), + ...(options.statePath ? { statePath: options.statePath } : {}), + tasks, + }) +} + +function normalizeDataSourceId(value: string): string { + const id = value.trim().replace(/^collection:\/\//u, '') + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(id)) { + throw new Error(`invalid Notion data source id: ${value}`) + } + return id.toLowerCase() +} + +function propertyText(row: FactoryTasksNotionPage, name: string): string +function propertyText(row: FactoryTasksNotionPage, name: string, required: true): string +function propertyText(row: FactoryTasksNotionPage, name: string, required: false): string | undefined +function propertyText( + row: FactoryTasksNotionPage, + name: string, + required = true, +): string | undefined { + const property = record(row.properties[name]) + let value: string | undefined + switch (property?.type) { + case 'title': + value = richText(property.title) + break + case 'rich_text': + value = richText(property.rich_text) + break + case 'select': + value = record(property.select)?.name as string | undefined + break + case 'status': + value = record(property.status)?.name as string | undefined + break + case 'url': + value = typeof property.url === 'string' ? property.url : undefined + break + case undefined: + break + default: + throw new Error( + `Factory Tasks row ${normalizeNotionPageId(row.id)} property ${name} has unsupported type ${String(property?.type)}`, + ) + } + value = typeof value === 'string' ? value.trim() : undefined + if (!value && required) { + throw new Error(`Factory Tasks row ${normalizeNotionPageId(row.id)} requires property ${name}`) + } + return value || undefined +} + +function propertyList(row: FactoryTasksNotionPage, name: string): string[] { + const property = record(row.properties[name]) + if (!property) return [] + if (property.type === 'multi_select') { + if (!Array.isArray(property.multi_select)) { + throw new Error(`Factory Tasks row ${normalizeNotionPageId(row.id)} property ${name} is malformed`) + } + return property.multi_select.map((entry) => { + const value = record(entry)?.name + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Factory Tasks row ${normalizeNotionPageId(row.id)} property ${name} is malformed`) + } + return value.trim() + }) + } + const scalar = propertyText(row, name, false) + return scalar?.split(',').map((entry) => entry.trim()).filter(Boolean) ?? [] +} + +function richText(value: unknown): string | undefined { + if (!Array.isArray(value)) return undefined + const text = value.map((entry) => { + const item = record(entry) + if (typeof item?.plain_text === 'string') return item.plain_text + const content = record(item?.text)?.content + return typeof content === 'string' ? content : '' + }).join('') + return text || undefined +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)] +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined +} diff --git a/src/intake/notion-relay-claim.test.ts b/src/intake/notion-relay-claim.test.ts index b65752c..9a0da4a 100644 --- a/src/intake/notion-relay-claim.test.ts +++ b/src/intake/notion-relay-claim.test.ts @@ -14,6 +14,7 @@ function fakeRelaySurface(options: { failWrites?: boolean } = {}) { delete: vi.fn(async () => undefined), }, channels: { + list: vi.fn(async () => [...channels.keys()].map((name) => ({ name }))), get: vi.fn(async (name: string) => { if (!channels.has(name)) throw Object.assign(new Error('missing'), { code: 'channel_not_found' }) return { name } @@ -87,6 +88,19 @@ describe('RelayChannelNotionClaimStore', () => { expect(fake.sendCount()).toBe(1) }) + it('discovers legacy destination claims by provider-native page prefix', async () => { + const fake = fakeRelaySurface() + const store = new RelayChannelNotionClaimStore({ workspaceKey: 'workspace-key', createRelay: fake.createRelay }) + await store.claim(claim) + await store.claim({ + ...claim, + sourceKey: 'notion:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:repo:agentworkforce/cloud', + }) + + await expect(store.findBySourcePrefix('notion:3b36800c-1c90-801d-b1cf-c8f2e1cff7cf:')) + .resolves.toEqual([claim]) + }) + it('leaves an incomplete durable channel and rejects when the claim record write fails', async () => { const fake = fakeRelaySurface({ failWrites: true }) const first = new RelayChannelNotionClaimStore({ workspaceKey: 'workspace-key', createRelay: fake.createRelay }) diff --git a/src/intake/notion-relay-claim.ts b/src/intake/notion-relay-claim.ts index db7a02e..e980efe 100644 --- a/src/intake/notion-relay-claim.ts +++ b/src/intake/notion-relay-claim.ts @@ -25,10 +25,10 @@ type RelayChannelClaimStoreOptions = { type ClaimRelay = Pick /** - * Stores one immutable claim per Notion source key in a workspace-global Relay - * channel. Channel-name uniqueness is the cross-dispatcher compare-and-set; - * message idempotency is deliberately not used because it is actor-scoped and - * expires after a bounded interval. + * Stores immutable Notion work-unit authorities and destination delivery + * records in workspace-global Relay channels. Channel-name uniqueness is the + * cross-dispatcher compare-and-set; message idempotency is deliberately not + * used because it is actor-scoped and expires after a bounded interval. */ export class RelayChannelNotionClaimStore implements NotionIntakeClaimStore { readonly #workspaceKey: string @@ -99,6 +99,21 @@ export class RelayChannelNotionClaimStore implements NotionIntakeClaimStore { return await readExistingClaim(relay, channel, sourceKey) } + async findBySourcePrefix(sourceKeyPrefix: string): Promise { + if (this.#disposed) throw new Error('Notion claim store has been disposed') + const relay = await this.#relay() + const channels = (await relay.channels.list({ includeArchived: true })) + .filter((channel) => /^factory-notion-claim-[0-9a-f]{64}$/u.test(channel.name)) + .sort((left, right) => left.name.localeCompare(right.name)) + const claims: NotionIntakeClaim[] = [] + for (const channel of channels) { + await relay.channels.join(channel.name) + const claim = await discoverClaim(relay, channel.name, sourceKeyPrefix) + if (claim) claims.push(claim) + } + return claims + } + async dispose(): Promise { this.#disposed = true await this.#relayReady?.catch(() => undefined) @@ -167,6 +182,17 @@ async function readExistingClaim( relay: ClaimRelay, channel: string, expectedSourceKey: string, +): Promise { + const claim = await readClaim(relay, channel) + if (claim.sourceKey !== expectedSourceKey) { + throw new Error(`durable Notion claim ${channel} does not match its source key`) + } + return claim +} + +async function readClaim( + relay: ClaimRelay, + channel: string, ): Promise { const messages = await listAllMessages(relay, channel) const records = messages @@ -178,12 +204,35 @@ async function readExistingClaim( ) } const [claim] = records - if (claim!.sourceKey !== expectedSourceKey) { - throw new Error(`durable Notion claim ${channel} does not match its source key`) - } return publicClaim(claim!) } +async function discoverClaim( + relay: ClaimRelay, + channel: string, + sourceKeyPrefix: string, +): Promise { + const messages = await listAllMessages(relay, channel) + const records = messages + .filter((message) => message.text.startsWith(`${CLAIM_MARKER}\n`)) + .flatMap((message) => { + try { + const parsed = claimRecordSchema.safeParse(JSON.parse(message.text.slice(CLAIM_MARKER.length + 1))) + return parsed.success ? [parsed.data] : [] + } catch { + return [] + } + }) + const matching = records.filter((record) => record.sourceKey.startsWith(sourceKeyPrefix)) + if (matching.length === 0) return undefined + if (records.length !== 1 || matching.length !== 1) { + throw new Error( + `durable Notion claim ${channel} has ${records.length} immutable claim records; refusing dispatch`, + ) + } + return publicClaim(matching[0]!) +} + async function listAllMessages(relay: ClaimRelay, channel: string): Promise { const messages: RelayMessage[] = [] let before: string | undefined diff --git a/src/intake/notion.test.ts b/src/intake/notion.test.ts index 422d5de..7c85f14 100644 --- a/src/intake/notion.test.ts +++ b/src/intake/notion.test.ts @@ -23,6 +23,8 @@ const roots: string[] = [] const durableClaims = new Map() const claims: NotionIntakeClaimStore = { get: vi.fn(async (sourceKey) => durableClaims.get(sourceKey)), + findBySourcePrefix: vi.fn(async (sourceKeyPrefix) => [...durableClaims.values()] + .filter((claim) => claim.sourceKey.startsWith(sourceKeyPrefix))), claim: vi.fn(async (claim) => { const existing = durableClaims.get(claim.sourceKey) if (existing) return { status: 'existing' as const, claim: existing } @@ -34,6 +36,7 @@ const claims: NotionIntakeClaimStore = { afterEach(async () => { durableClaims.clear() vi.mocked(claims.get).mockClear() + vi.mocked(claims.findBySourcePrefix).mockClear() vi.mocked(claims.claim).mockClear() await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -116,6 +119,7 @@ describe('Notion spec intake', () => { expect(tasks).toHaveLength(1) expect(tasks[0]).toMatchObject({ pageId, + workUnitKey: `notion:${pageId}`, bootstrap: true, sourceKey: `notion:${pageId}:repo:agentworkforce/cloud`, target: { repo: 'AgentWorkforce/cloud' }, @@ -445,6 +449,7 @@ describe('Notion spec intake', () => { const github = fakeGithub({ visibility: 'private' }) const unavailableClaims: NotionIntakeClaimStore = { get: vi.fn(async () => undefined), + findBySourcePrefix: vi.fn(async () => []), claim: vi.fn(async () => { throw new Error('shared claim write failed') }), } @@ -488,6 +493,122 @@ describe('Notion spec intake', () => { })) }) + it('migrates a legacy destination claim before refusing an edited destination', async () => { + const { root, manifest } = await fixtureManifest('private mounted body', { + bootstrap: bootstrap({ repo: 'Example/one', labels: [] }), + }) + roots.push(root) + const [original] = await normalizeNotionManifest(manifest) + durableClaims.set(original!.sourceKey, { + sourceKey: original!.sourceKey, + digest: original!.digest, + claimedAt: '2026-08-06T20:00:00.000Z', + }) + manifest.tasks[0]!.bootstrap!.targets = [{ repo: 'Example/two', labels: [] }] + const github = fakeGithub({ visibility: 'private' }) + + const report = await runNotionIntake({ manifest, dispatch: true, claims, github }) + + expect(report).toMatchObject({ + ok: false, + results: [{ + status: 'blocked', + target: { repo: 'Example/two' }, + reason: 'durable Notion claim digest does not match the mounted spec', + }], + }) + expect(durableClaims.get(`notion:${pageId}`)).toMatchObject({ digest: original!.digest }) + expect(github.createIssue).not.toHaveBeenCalled() + }) + + it('refuses disagreeing legacy destination claims without writing a canonical claim', async () => { + const { root, manifest } = await fixtureManifest('private mounted body', { + bootstrap: bootstrap({ repo: 'Example/current', labels: [] }), + }) + roots.push(root) + const [task] = await normalizeNotionManifest(manifest) + const workUnitKey = `notion:${pageId}` + durableClaims.set(`${workUnitKey}:repo:example/one`, { + sourceKey: `${workUnitKey}:repo:example/one`, + digest: task!.digest, + claimedAt: '2026-08-06T20:00:00.000Z', + }) + durableClaims.set(`${workUnitKey}:repo:example/two`, { + sourceKey: `${workUnitKey}:repo:example/two`, + digest: 'disagreeing-digest', + claimedAt: '2026-08-06T20:01:00.000Z', + }) + const github = fakeGithub({ visibility: 'private' }) + + const report = await runNotionIntake({ manifest, dispatch: true, claims, github }) + + expect(report.results[0]).toMatchObject({ + status: 'blocked', + reason: 'legacy Notion claims disagree for the provider-native work unit; refusing dispatch', + }) + expect(durableClaims.has(workUnitKey)).toBe(false) + expect(github.createIssue).not.toHaveBeenCalled() + }) + + it('continues refusing disagreeing legacy destination claims on a repeated run', async () => { + const { root, manifest } = await fixtureManifest('private mounted body', { + bootstrap: bootstrap({ repo: 'Example/current', labels: [] }), + }) + roots.push(root) + const [task] = await normalizeNotionManifest(manifest) + const workUnitKey = `notion:${pageId}` + durableClaims.set(`${workUnitKey}:repo:example/one`, { + sourceKey: `${workUnitKey}:repo:example/one`, + digest: task!.digest, + claimedAt: '2026-08-06T20:00:00.000Z', + }) + durableClaims.set(`${workUnitKey}:repo:example/two`, { + sourceKey: `${workUnitKey}:repo:example/two`, + digest: 'disagreeing-digest', + claimedAt: '2026-08-06T20:01:00.000Z', + }) + const github = fakeGithub({ visibility: 'private' }) + + const first = await runNotionIntake({ manifest, dispatch: true, claims, github }) + const second = await runNotionIntake({ manifest, dispatch: true, claims, github }) + + for (const report of [first, second]) { + expect(report.results[0]).toMatchObject({ + status: 'blocked', + reason: 'legacy Notion claims disagree for the provider-native work unit; refusing dispatch', + }) + } + expect(durableClaims.has(workUnitKey)).toBe(false) + expect(github.createIssue).not.toHaveBeenCalled() + }) + + it('migrates agreeing legacy destination claims to one canonical claim', async () => { + const { root, manifest } = await fixtureManifest('private mounted body', { + bootstrap: bootstrap({ repo: 'Example/current', labels: [] }), + }) + roots.push(root) + const [task] = await normalizeNotionManifest(manifest) + const workUnitKey = `notion:${pageId}` + for (const [repo, claimedAt] of [ + ['one', '2026-08-06T20:00:00.000Z'], + ['two', '2026-08-06T20:01:00.000Z'], + ] as const) { + const sourceKey = `${workUnitKey}:repo:example/${repo}` + durableClaims.set(sourceKey, { sourceKey, digest: task!.digest, claimedAt }) + } + const github = fakeGithub({ visibility: 'private' }) + + const report = await runNotionIntake({ manifest, dispatch: true, claims, github }) + + expect(report.results[0]).toMatchObject({ status: 'dispatched' }) + expect(durableClaims.get(workUnitKey)).toEqual({ + sourceKey: workUnitKey, + digest: task!.digest, + claimedAt: '2026-08-06T20:00:00.000Z', + }) + expect(github.createIssue).toHaveBeenCalledTimes(1) + }) + it('serializes overlapping runs and creates one lifecycle issue', async () => { const { root, manifest } = await fixtureManifest('private mounted body', { bootstrap: bootstrap({ repo: 'AgentWorkforce/cloud', labels: [] }), @@ -630,6 +751,7 @@ describe('Notion spec intake', () => { const events: string[] = [] const unavailableClaims: NotionIntakeClaimStore = { get: vi.fn(async () => undefined), + findBySourcePrefix: vi.fn(async () => []), claim: vi.fn(async () => { events.push('claim') throw new Error('durable claim unavailable') diff --git a/src/intake/notion.ts b/src/intake/notion.ts index f8dc7f7..0c3d38e 100644 --- a/src/intake/notion.ts +++ b/src/intake/notion.ts @@ -6,9 +6,11 @@ import { dirname, isAbsolute, join, resolve } from 'node:path' import lockfile from 'proper-lockfile' import { z } from 'zod' +import { dispatchNotionPageIdentity } from '../dispatch/work-unit-identity' + const INTAKE_LOCK_STALE_MS = 60_000 -const recipeSchema = z.enum(['single', 'workflow', 'team']) +export const notionRecipeSchema = z.enum(['single', 'workflow', 'team']) const repoTargetSchema = z.object({ repo: z.string().regex(/^[^/\s]+\/[^/\s]+$/u, 'repo must be owner/name'), @@ -45,12 +47,12 @@ const bootstrapSchema = z.object({ reason: z.string().trim().min(1), status: z.literal('ready'), title: z.string().trim().min(1), - recipe: recipeSchema, + recipe: notionRecipeSchema, summary: z.string().trim().min(1), targets: z.array(targetSchema).min(1), }).strict() -const manifestSchema = z.object({ +export const manifestSchema = z.object({ version: z.literal(1), mountRoot: z.string().trim().min(1).default('.integrations/notion'), workerMountRoot: z.string().trim().min(1).default('.integrations/notion'), @@ -62,12 +64,13 @@ const manifestSchema = z.object({ }).strict()).min(1), }).strict() -export type NotionRecipe = z.infer +export type NotionRecipe = z.infer export type NotionIntakeTarget = z.infer export type NotionIntakeManifest = z.infer export interface NormalizedNotionTask { pageId: string + workUnitKey: string sourceKey: string sourcePath: string workerSourcePath: string @@ -125,6 +128,8 @@ export interface NotionIntakeClaim { export interface NotionIntakeClaimStore { get(sourceKey: string): Promise + /** Enumerate legacy destination claims so they can be bound to the immutable page authority. */ + findBySourcePrefix(sourceKeyPrefix: string): Promise claim(input: NotionIntakeClaim): Promise<{ status: 'claimed' | 'existing' claim: NotionIntakeClaim @@ -280,6 +285,7 @@ export async function normalizeNotionManifest(manifest: NotionIntakeManifest): P for (const task of manifest.tasks) { const pageId = normalizeNotionPageId(task.page) + const workUnitKey = dispatchNotionPageIdentity(pageId) const sourcePath = join(manifest.mountRoot, 'pages', pageId, 'content.md') const content = await readFile(sourcePath, 'utf8') const spec = task.bootstrap @@ -296,6 +302,7 @@ export async function normalizeNotionManifest(manifest: NotionIntakeManifest): P seen.add(sourceKey) normalized.push({ pageId, + workUnitKey, sourceKey, sourcePath, workerSourcePath: 'repo' in target || manifest.workerMountTransport.kind === 'relay-channel' @@ -355,7 +362,7 @@ export function parseChiefSpecHeader(content: string): { } const title = requiredField(fields, 'title') const summary = requiredField(fields, 'summary') - const recipe = recipeSchema.parse(requiredField(fields, 'recipe').toLowerCase()) + const recipe = notionRecipeSchema.parse(requiredField(fields, 'recipe').toLowerCase()) const repos = splitField(fields.get('repos')).map((repo) => repoTargetSchema.parse({ repo, ...(fields.get('public-summary') ? { publicSummary: fields.get('public-summary') } : {}), @@ -446,7 +453,8 @@ async function publishRepoTask( if (currentDigest !== task.digest) { return { ...base, status: 'blocked', issue: existing, reason: 'mounted spec changed after the lifecycle issue was created' } } - let claim = await observeNotionClaim(task, input) + await ensureNotionWorkUnitClaim(task, input) + let claim = await observeNotionDeliveryClaim(task, input) if (!claim) { if (!receipt) { return { @@ -456,7 +464,7 @@ async function publishRepoTask( reason: 'lifecycle issue marker has neither a durable shared claim nor a local migration receipt', } } - claim = (await claimNotionTask(task, input)).claim + claim = (await claimNotionDelivery(task, input)).claim } const bodyDelivery = contractDeliveryFromBody(existing.body) if (input.manifest.workerMountTransport.kind === 'local') { @@ -519,8 +527,9 @@ async function publishRepoTask( if (missing.length > 0) { return { ...base, status: 'blocked', reason: `missing required GitHub labels: ${missing.join(', ')}` } } + await ensureNotionWorkUnitClaim(task, input) const delivery = await prepareContractDelivery(task, input) - const claim = await claimNotionTask(task, input) + const claim = await claimNotionDelivery(task, input) if (claim.status === 'existing') { return { ...base, @@ -571,7 +580,8 @@ async function dispatchWorkspaceTask( if (receipt.digest !== task.digest) { return { ...base, status: 'blocked', agent: receipt.agent, node: receipt.node, reason: 'mounted spec changed after workspace dispatch' } } - await claimNotionTask(task, input) + await ensureNotionWorkUnitClaim(task, input) + await claimNotionDelivery(task, input) const needsPortableMigration = input.manifest.workerMountTransport.kind !== 'local' && !receipt.delivery if (needsPortableMigration) { if (!input.workspace?.redispatch) { @@ -583,7 +593,7 @@ async function dispatchWorkspaceTask( reason: 'portable workspace mount migration requires a workspace redispatcher', } } - const migrationClaim = await claimNotionTask(task, input, `${task.sourceKey}:portable-mount`) + const migrationClaim = await claimNotionDelivery(task, input, `${task.sourceKey}:portable-mount`) if (migrationClaim.status === 'existing') { return { ...base, @@ -628,7 +638,8 @@ async function dispatchWorkspaceTask( const suffix = createHash('sha256').update(task.sourceKey).digest('hex').slice(0, 8) const name = `notion-${task.pageId.slice(-8)}-${suffix}` - const claim = await claimNotionTask(task, input) + await ensureNotionWorkUnitClaim(task, input) + const claim = await claimNotionDelivery(task, input) if (claim.status === 'existing') { if (!input.workspace.find) { return { @@ -647,7 +658,7 @@ async function dispatchWorkspaceTask( } const migrationSourceKey = `${task.sourceKey}:portable-mount` if (input.manifest.workerMountTransport.kind !== 'local' && - await observeNotionClaim(task, input, migrationSourceKey)) { + await observeNotionDeliveryClaim(task, input, migrationSourceKey)) { return { ...base, status: 'blocked', @@ -693,7 +704,49 @@ async function dispatchWorkspaceTask( } } -async function observeNotionClaim( +async function ensureNotionWorkUnitClaim( + task: NormalizedNotionTask, + input: Parameters[0], +): Promise { + if (!input.claims) { + throw new Error('dispatch requires a durable Agent Relay Notion claim store') + } + + const existing = await input.claims.get(task.workUnitKey) + if (existing) { + assertNotionClaim(existing, task.workUnitKey, task.digest) + return existing + } + + const legacyClaims = (await input.claims.findBySourcePrefix(`${task.workUnitKey}:`)) + .sort((left, right) => left.claimedAt.localeCompare(right.claimedAt) || + left.sourceKey.localeCompare(right.sourceKey)) + if (legacyClaims.length > 0) { + const legacyDigests = new Set(legacyClaims.map((claim) => claim.digest)) + if (legacyDigests.size > 1) { + throw new Error('legacy Notion claims disagree for the provider-native work unit; refusing dispatch') + } + const [authoritative] = legacyClaims + const migrated = await input.claims.claim({ + sourceKey: task.workUnitKey, + digest: authoritative!.digest, + claimedAt: authoritative!.claimedAt, + }) + assertNotionClaim(migrated.claim, task.workUnitKey, authoritative!.digest) + assertNotionClaim(migrated.claim, task.workUnitKey, task.digest) + return migrated.claim + } + + const result = await input.claims.claim({ + sourceKey: task.workUnitKey, + digest: task.digest, + claimedAt: (input.now?.() ?? new Date()).toISOString(), + }) + assertNotionClaim(result.claim, task.workUnitKey, task.digest) + return result.claim +} + +async function observeNotionDeliveryClaim( task: NormalizedNotionTask, input: Parameters[0], sourceKey = task.sourceKey, @@ -711,7 +764,7 @@ async function observeNotionClaim( return claim } -async function claimNotionTask( +async function claimNotionDelivery( task: NormalizedNotionTask, input: Parameters[0], sourceKey = task.sourceKey, @@ -733,6 +786,15 @@ async function claimNotionTask( return result } +function assertNotionClaim(claim: NotionIntakeClaim, sourceKey: string, digest: string): void { + if (claim.sourceKey !== sourceKey) { + throw new Error('durable Notion claim does not match the requested source key') + } + if (claim.digest !== digest) { + throw new Error('durable Notion claim digest does not match the mounted spec') + } +} + function normalizedBootstrapSpec(bootstrap: z.infer, pageId: string) { const authorizedPageId = normalizeNotionPageId(bootstrap.authorizedPageId) if (authorizedPageId !== pageId) {