From 2cf8bb4668f4d88df77fae2fe33a229f867172d7 Mon Sep 17 00:00:00 2001 From: Petr Plenkov <6381507+ThePlenkov@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:06:35 +0200 Subject: [PATCH 1/5] feat(flow): add source-free transport indexing Retain transport inventory and unresolved source-boundary descriptors without materializing source files. Strict checkout remains fail-closed, while CLI and MCP expose the explicit index-only operation. --- .../add-flow-index-only/.openspec.yaml | 2 + .../changes/add-flow-index-only/design.md | 65 +++++++++++ .../changes/add-flow-index-only/proposal.md | 38 +++++++ .../specs/adt-flow-index-only/spec.md | 50 +++++++++ openspec/changes/add-flow-index-only/tasks.md | 16 +++ packages/adt-flow/README.md | 23 ++++ packages/adt-flow/src/commands/flow.ts | 58 +++++++++- packages/adt-flow/src/index.ts | 1 + packages/adt-flow/src/service.ts | 47 ++++++++ packages/adt-flow/src/types.ts | 7 ++ packages/adt-flow/tests/flow-command.test.ts | 51 +++++++-- packages/adt-flow/tests/service.test.ts | 52 +++++++++ packages/adt-mcp/README.md | 18 +++ .../adt-mcp/src/lib/tools/flow-checkout-tr.ts | 6 +- .../adt-mcp/src/lib/tools/flow-index-tr.ts | 103 ++++++++++++++++++ packages/adt-mcp/src/lib/tools/index.ts | 2 + .../adt-mcp/src/lib/tools/scope-catalogue.ts | 1 + .../tests/flow-index-tr.vitest.test.ts | 98 +++++++++++++++++ packages/adt-mcp/vitest.config.ts | 5 +- 19 files changed, 629 insertions(+), 14 deletions(-) create mode 100644 openspec/changes/add-flow-index-only/.openspec.yaml create mode 100644 openspec/changes/add-flow-index-only/design.md create mode 100644 openspec/changes/add-flow-index-only/proposal.md create mode 100644 openspec/changes/add-flow-index-only/specs/adt-flow-index-only/spec.md create mode 100644 openspec/changes/add-flow-index-only/tasks.md create mode 100644 packages/adt-mcp/src/lib/tools/flow-index-tr.ts create mode 100644 packages/adt-mcp/tests/flow-index-tr.vitest.test.ts diff --git a/openspec/changes/add-flow-index-only/.openspec.yaml b/openspec/changes/add-flow-index-only/.openspec.yaml new file mode 100644 index 000000000..abd7c5ae1 --- /dev/null +++ b/openspec/changes/add-flow-index-only/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-25 diff --git a/openspec/changes/add-flow-index-only/design.md b/openspec/changes/add-flow-index-only/design.md new file mode 100644 index 000000000..87e8fc339 --- /dev/null +++ b/openspec/changes/add-flow-index-only/design.md @@ -0,0 +1,65 @@ +## Context + +See `proposal.md`. Existing partial checkout already persists omitted-object +descriptors, but it also permits exact source materialization. Existing strict +checkout intentionally fails before all filesystem mutation. Neither surface +captures an unresolved boundary without changing source behavior. + +## Goals / Non-Goals + +**Goals:** + +- Reuse the existing manifest, descriptor schemas, omission model, and shared + flow service. +- Add one explicit source-free operation with matching CLI and MCP adapters. +- Preserve current strict and partial checkout semantics byte-for-byte. + +**Non-Goals:** + +- Reading, reconstructing, or writing source bodies. +- Git, CI, merge-request, transport-release, or vendor-specific workflow + behavior. +- Treating an inexact object as materialized or exact. + +## Decisions + +### Use an explicit `index-only` operation rather than changing checkout defaults + +The operation is opt-in because strict checkout's no-mutation guarantee is a +valuable safety boundary. Making partial checkout automatic would allow a +mixed transport to change source files while leaving unresolved components. +An index-only call makes the persistence intent visible and has no source-side +effect. + +### Reuse the current manifest and omission descriptor formats + +The service will build the same scoped manifest and apply existing selector +rules. It will create transport descriptors marked incomplete and omitted +descriptors only for entries that cannot be materialized. Exact entries remain +inventory-only until a normal checkout materializes them. This avoids a second +identity schema or source-resolution implementation. + +### Expose thin CLI and MCP adapters over the public service + +The CLI and MCP will parse the same explicit operation and delegate to the +same service result. They do not own persistence, SAP selection, or delivery +workflow, preserving the established parity boundary. + +## Risks / Trade-offs + +- An index can become stale after SAP history changes → normal checkout still + rebuilds authoritative provenance and replaces incomplete state only after + an exact result. +- Metadata selectors can require ADT metadata reads → bounded metadata reads + are allowed; source reads remain forbidden. +- Consumers may mistake inventory for source → result and descriptor state + identify omissions and the operation never reports source changes. + +## Migration Plan + +1. Release the additive service and adapter operation. +2. Consumers that need durable recovery state call index-only after a strict + boundary failure, then persist the resulting `.adt` state outside source + branches. +3. Roll back by stopping those calls; existing descriptors are safe to retain + or delete and normal checkout behavior is unchanged. diff --git a/openspec/changes/add-flow-index-only/proposal.md b/openspec/changes/add-flow-index-only/proposal.md new file mode 100644 index 000000000..904275a46 --- /dev/null +++ b/openspec/changes/add-flow-index-only/proposal.md @@ -0,0 +1,38 @@ +## Why + +Transport history can prove that an object cannot yet be materialized without +making the transport itself disappear from local recovery state. Callers need +to retain a safe, source-free inventory of that fact while keeping normal +checkout fail-closed and non-mutating. + +## What Changes + +- Add an explicit, vendor-neutral `adt-flow` index-only operation for a + transport scope. +- Persist deterministic `.adt` transport inventory and omission descriptors + without reading source bodies or changing format-owned source files. +- Expose the same operation through the flow CLI and MCP adapters, using the + shared service result and bounded diagnostics. +- Preserve existing checkout semantics: strict checkout still rejects an + inexact boundary before filesystem mutation, and partial checkout remains an + explicit source-materialization mode. + +## Capabilities + +### New Capabilities + +- `adt-flow-index-only`: Persist source-free transport inventory and + non-materializable object diagnostics independently from source checkout. + +### Modified Capabilities + +- None. + +## Impact + +- Affected packages: `@abapify/adt-flow`, `@abapify/adt-cli`, and + `@abapify/adt-mcp`. +- Additive public API and command/tool surface; no dependency, release, or + transport-system specific behavior. +- Rollback consists of removing the additive command/tool and its descriptors; + existing checkout and source files are unaffected. diff --git a/openspec/changes/add-flow-index-only/specs/adt-flow-index-only/spec.md b/openspec/changes/add-flow-index-only/specs/adt-flow-index-only/spec.md new file mode 100644 index 000000000..b488cb930 --- /dev/null +++ b/openspec/changes/add-flow-index-only/specs/adt-flow-index-only/spec.md @@ -0,0 +1,50 @@ +## Purpose + +Provide a durable, source-free record of transport inventory and unresolved +source boundaries without weakening exact source checkout guarantees. + +## ADDED Requirements + +### Requirement: Flow can index a transport without materializing source + +`adt-flow` SHALL provide an explicit index-only operation for a transport scope +that persists deterministic `.adt` transport and object descriptors without +reading source bodies or changing format-owned source paths. + +#### Scenario: Inexact source boundary is indexed + +- **GIVEN** a transport contains a relevant object whose source boundary is + inexact +- **WHEN** an index-only operation is requested for that transport +- **THEN** the transport inventory and an omitted-object descriptor retain the + object identity, component, source transport, and bounded diagnostic +- **THEN** no source body is read and no format-owned source path is changed + +#### Scenario: Exact source remains unmaterialized during indexing + +- **GIVEN** a transport contains an exact source component +- **WHEN** an index-only operation is requested +- **THEN** the transport inventory is persisted without selecting or writing + the component's source files + +#### Scenario: Normal checkout remains strict + +- **GIVEN** a transport contains a relevant object whose source boundary is + inexact +- **WHEN** normal checkout is requested without the index-only operation +- **THEN** checkout fails with its typed bounded diagnostic before any + repository path is changed + +### Requirement: Index-only flow is available through equivalent adapters + +The CLI and MCP flow adapters SHALL expose the same explicit index-only +operation and SHALL return equivalent structured results without source bodies +or credentials. + +#### Scenario: CLI and MCP index the same fixture + +- **GIVEN** the CLI and MCP receive the same flow configuration, transport + manifest, and repository tree +- **WHEN** each requests index-only flow for the transport +- **THEN** both return equivalent inventory, descriptor, and omission results +- **THEN** neither changes format-owned source paths diff --git a/openspec/changes/add-flow-index-only/tasks.md b/openspec/changes/add-flow-index-only/tasks.md new file mode 100644 index 000000000..0dd3909ae --- /dev/null +++ b/openspec/changes/add-flow-index-only/tasks.md @@ -0,0 +1,16 @@ +## 1. Flow service and descriptors + +- [x] 1.1 Add a failing `adt-flow` service test for an inexact manifest indexed without source reads or source-file changes. +- [x] 1.2 Implement the explicit index-only service operation by reusing manifest, inventory, omission, and descriptor logic; verify the focused `adt-flow` test passes. +- [x] 1.3 Add a regression test that strict checkout still leaves the repository unchanged on the same inexact manifest; verify it passes. + +## 2. Delivery adapters + +- [x] 2.1 Add failing CLI command tests for index-only transport flow and its source-free structured output. +- [x] 2.2 Implement the CLI adapter as a thin delegation to the public flow service; verify CLI tests pass. +- [x] 2.3 Add MCP parity coverage for the same fixture and implement the matching tool delegation; verify the parity test passes. + +## 3. Documentation and verification + +- [x] 3.1 Document the index-only command and the distinction from strict and partial checkout; verify the README example is accurate. +- [x] 3.2 Run focused Nx test, typecheck, lint, build, format-check, OpenSpec strict validation, and `git diff --check`; record any unrelated baseline blocker. diff --git a/packages/adt-flow/README.md b/packages/adt-flow/README.md index d91fac0f8..f6986c873 100644 --- a/packages/adt-flow/README.md +++ b/packages/adt-flow/README.md @@ -27,6 +27,20 @@ adt flow checkout tr DEVK900001 adt flow checkout tr DEVK900001,DEVK900002 --base ``` +To persist only transport inventory and unresolved-boundary descriptors under +`.adt`, without reading or materializing source files, use the explicit +index-only command: + +```text +adt flow index tr DEVK900001,DEVK900002 +``` + +`checkout` remains strict: if any versioned component has no exact source +boundary, it fails without changing the workspace. `checkout --partial` is a +separate explicit opt-in that materializes only exact objects. `index` never +materializes source; it retains the complete transport inventory and records +every unresolved component as an `omitted` descriptor for a later retry. + ```typescript import { createAdtFlowService, @@ -53,6 +67,15 @@ await flow.checkout({ include: { objectTypes: ['CLAS', 'INTF'] }, }, }); + +await flow.index({ + root: process.cwd(), + transports: ['DEVK900001'], + config: { + format: { id: 'abapgit', options: { folderLogic: 'prefix' } }, + include: { objectTypes: ['CLAS', 'INTF'] }, + }, +}); ``` The service invokes no Git command. It reconciles format-owned files and diff --git a/packages/adt-flow/src/commands/flow.ts b/packages/adt-flow/src/commands/flow.ts index 6cdaa2a23..474b2719d 100644 --- a/packages/adt-flow/src/commands/flow.ts +++ b/packages/adt-flow/src/commands/flow.ts @@ -291,7 +291,7 @@ function checkoutTrCommand( // Commander normalizes --partial-report to partialReport at runtime; // retain the dashed spelling for direct plugin callers and tests. const report = partialReportPath( - args.partialReport ?? args['partial-report'], + args['partialReport'] ?? args['partial-report'], ctx.cwd, realRoot, ); @@ -327,6 +327,56 @@ function checkoutTrCommand( }; } +function indexTrCommand( + dependencies: FlowCommandDependencies, +): CliCommandPlugin { + return { + name: 'tr', + description: 'Index transport inventory without materializing source', + arguments: [ + { + name: '', + description: 'Transport number or comma-separated transport scope', + }, + ], + async execute(args, ctx) { + if (!ctx.getAdtClient) { + throw new AdtFlowError( + 'sap_operation_failed', + 'An authenticated ADT client is required.', + ); + } + const config = flowConfig(ctx); + const format = dependencies.getFormat(config.format.id); + if (!format) { + throw new AdtFlowError( + 'format_unsupported', + `Format plugin "${config.format.id}" is not registered.`, + ); + } + const client = (await ctx.getAdtClient()) as AdtClient; + const result = await dependencies.createService(client, format).index({ + root: ctx.cwd, + transports: transports(args['transport']), + config, + }); + ctx.logger.info( + `Indexed ${result.requestedTransports.join(', ')}: ` + + `${result.descriptors.length} descriptors, ${result.skipped.length} omissions.`, + ); + for (const skipped of result.skipped) { + ctx.logger.warn( + `Indexed omission ${skipped.object} (${skipped.component}; ${skipped.diagnostic}).`, + ); + } + ctx.logger.info( + `SAP calls: manifest=${result.sapCalls.manifest}, metadata=${result.sapCalls.metadata}, ` + + `source=${result.sapCalls.source}.`, + ); + }, + }; +} + export function createFlowCommand( overrides: Partial = {}, ): CliCommandPlugin { @@ -340,6 +390,12 @@ export function createFlowCommand( description: 'Reconcile a source tree to an ADT boundary', subcommands: [checkoutTrCommand(dependencies)], }, + { + name: 'index', + description: + 'Persist transport inventory without source materialization', + subcommands: [indexTrCommand(dependencies)], + }, ], }; } diff --git a/packages/adt-flow/src/index.ts b/packages/adt-flow/src/index.ts index a289563a7..056eefbe4 100644 --- a/packages/adt-flow/src/index.ts +++ b/packages/adt-flow/src/index.ts @@ -25,6 +25,7 @@ export { AdtFlowError, type FlowCheckoutDependencies, type FlowCheckoutInput, + type FlowIndexInput, type FlowCheckoutMode, type FlowCheckoutResult, type FlowErrorCode, diff --git a/packages/adt-flow/src/service.ts b/packages/adt-flow/src/service.ts index 482ae4ad0..0c3279f2e 100644 --- a/packages/adt-flow/src/service.ts +++ b/packages/adt-flow/src/service.ts @@ -36,6 +36,7 @@ import { type FlowCheckoutDependencies, type FlowCheckoutInput, type FlowCheckoutResult, + type FlowIndexInput, type FlowObjectIdentity, type FlowObjectModel, type FlowSkippedObject, @@ -885,6 +886,7 @@ async function processGroup(ctx: ProcessGroupContext): Promise { export interface AdtFlowService { checkout(input: FlowCheckoutInput): Promise; + index(input: FlowIndexInput): Promise; } interface CheckoutContext { @@ -1567,6 +1569,48 @@ async function checkoutFlow( ); } +async function indexFlow( + input: FlowIndexInput, + dependencies: FlowCheckoutDependencies, +): Promise { + // Reuse partial manifest classification so inexact entries become durable + // omissions, but deliberately skip every source/materialization path. + const ctx = createCheckoutContext( + { ...input, mode: 'head', partial: true }, + dependencies, + ); + const manifestContext = await buildManifestAndGroups(ctx); + const indexed: ProcessedGroups = { + desired: [], + descriptorPaths: [], + ownedPaths: new Set(), + ownedOwners: new Map(), + reusedIndexedComponent: false, + }; + await addOmittedObjectDescriptors( + ctx, + manifestContext.manifest, + manifestContext.skipped, + indexed, + ); + await addTransportDescriptors(ctx, manifestContext.manifest, indexed, true); + indexed.desired.sort((left, right) => compareStrings(left.path, right.path)); + const plan = await planRepositoryChanges( + ctx.root, + indexed.desired, + indexed.ownedPaths, + indexed.ownedOwners, + ); + await applyRepositoryPlan(ctx.root, plan); + return buildCheckoutResult( + ctx, + manifestContext.manifest, + manifestContext.skipped, + plan, + indexed, + ); +} + export function createAdtFlowService( dependencies: FlowCheckoutDependencies, ): AdtFlowService { @@ -1574,5 +1618,8 @@ export function createAdtFlowService( async checkout(input): Promise { return checkoutFlow(input, dependencies); }, + async index(input): Promise { + return indexFlow(input, dependencies); + }, }; } diff --git a/packages/adt-flow/src/types.ts b/packages/adt-flow/src/types.ts index 47db512a7..5875a167c 100644 --- a/packages/adt-flow/src/types.ts +++ b/packages/adt-flow/src/types.ts @@ -68,6 +68,13 @@ export interface FlowCheckoutInput { config: FlowConfig; } +/** Persist transport inventory and unresolved-boundary descriptors only. */ +export interface FlowIndexInput { + root: string; + transports: string[]; + config: FlowConfig; +} + export interface FlowSkippedObject { object: string; component: string; diff --git a/packages/adt-flow/tests/flow-command.test.ts b/packages/adt-flow/tests/flow-command.test.ts index 53b9faa3e..22b047af8 100644 --- a/packages/adt-flow/tests/flow-command.test.ts +++ b/packages/adt-flow/tests/flow-command.test.ts @@ -16,6 +16,8 @@ import type { FormatPlugin, } from '@abapify/adt-plugin'; import { createFlowCommand } from '../src/commands/flow'; +import type { AdtFlowService } from '../src/service'; +import type { FlowCheckoutResult } from '../src/types'; const format = { id: 'abapgit', @@ -28,6 +30,10 @@ function leaf(command: CliCommandPlugin): CliCommandPlugin { return command.subcommands?.[0]?.subcommands?.[0] as CliCommandPlugin; } +function indexLeaf(command: CliCommandPlugin): CliCommandPlugin { + return command.subcommands?.[1]?.subcommands?.[0] as CliCommandPlugin; +} + const emptyCheckoutResult = { mode: 'head' as const, requestedTransports: ['DEVK900001'], @@ -40,18 +46,23 @@ const emptyCheckoutResult = { skipped: [], sapCalls: { manifest: 1, metadata: 1, source: 1 }, fastPath: 'none' as const, -}; +} satisfies FlowCheckoutResult; + +function makeCheckout(overrides: Partial = {}) { + return vi.fn(async () => ({ + ...emptyCheckoutResult, + ...overrides, + })); +} -function makeCheckout( - overrides: Partial = {}, -): ReturnType { - return vi.fn(async () => ({ ...emptyCheckoutResult, ...overrides })); +function makeIndex() { + return vi.fn(async () => emptyCheckoutResult); } -function makeCommand(checkout: ReturnType) { +function makeCommand(checkout: AdtFlowService['checkout']) { return createFlowCommand({ getFormat: vi.fn(() => format), - createService: vi.fn(() => ({ checkout })), + createService: vi.fn(() => ({ checkout, index: makeIndex() })), }); } @@ -74,6 +85,28 @@ async function withTempRoot(fn: (root: string) => Promise): Promise { } describe('flow CLI command', () => { + it('exposes an explicit source-free flow index tr hierarchy', async () => { + const index = makeIndex(); + const command = createFlowCommand({ + getFormat: vi.fn(() => format), + createService: vi.fn(() => ({ checkout: makeCheckout(), index })), + }); + const ctx = makeContext('/workspace'); + + expect(command.subcommands?.[1]?.name).toBe('index'); + expect(indexLeaf(command).name).toBe('tr'); + await indexLeaf(command).execute?.( + { transport: 'DEVK900002, DEVK900001' }, + ctx, + ); + + expect(index).toHaveBeenCalledWith({ + root: '/workspace', + transports: ['DEVK900002', 'DEVK900001'], + config: ctx.config['flow'], + }); + }); + it('exposes an explicit flow checkout tr hierarchy and applies base mode', async () => { const checkout = vi.fn(async () => ({ mode: 'base' as const, @@ -96,7 +129,7 @@ describe('flow CLI command', () => { })); const command = createFlowCommand({ getFormat: vi.fn(() => format), - createService: vi.fn(() => ({ checkout })), + createService: vi.fn(() => ({ checkout, index: makeIndex() })), }); const info = vi.fn(); const warn = vi.fn(); @@ -125,7 +158,7 @@ describe('flow CLI command', () => { transports: ['DEVK900002', 'DEVK900001'], mode: 'base', partial: false, - config: ctx.config.flow, + config: ctx.config['flow'], }); expect(info).toHaveBeenCalledWith( expect.stringContaining('1 changed, 0 moved, 0 removed'), diff --git a/packages/adt-flow/tests/service.test.ts b/packages/adt-flow/tests/service.test.ts index fc7b28d8c..4cda7f522 100644 --- a/packages/adt-flow/tests/service.test.ts +++ b/packages/adt-flow/tests/service.test.ts @@ -715,6 +715,58 @@ describe('transport checkout', () => { ).rejects.toMatchObject({ code: 'manifest_inexact' }); expect(ports.readSource).not.toHaveBeenCalled(); expect(ports.loadObject).not.toHaveBeenCalled(); + await expect( + readFile(join(workspace, '.adt/tr/DEVK900001.json'), 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('indexes an inexact transport without reading or materializing source', async () => { + const workspace = await root(); + const current = manifest('modified', version('before'), version('after')); + current.entries.push({ + object: { + pgmid: 'R3TR', + type: 'CLAS', + name: 'ZCL_ZZZ_INEXACT', + packageName: 'ZROOT_FEATURE', + }, + component: { id: 'main' }, + sourceTransport: 'DEVK900001', + changeKind: 'ambiguous', + exact: false, + diagnostic: { + code: 'SOURCE_HISTORY_SCOPE_VERSION_MISSING', + message: 'No exact version belongs to the scope.', + }, + }); + const ports = dependencies(() => current); + + const result = await createAdtFlowService(ports).index({ + root: workspace, + transports: ['DEVK900001'], + config, + }); + + expect(result.changed).toEqual([]); + expect(result.skipped).toEqual([ + { + object: 'CLAS/ZCL_ZZZ_INEXACT', + component: 'main', + diagnostic: 'SOURCE_HISTORY_SCOPE_VERSION_MISSING', + sourceTransport: 'DEVK900001', + }, + ]); + expect(ports.readSource).not.toHaveBeenCalled(); + expect(ports.loadObject).not.toHaveBeenCalled(); + await expect( + readFile(join(workspace, 'src/feature/zcl_sample.clas.abap'), 'utf8'), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + readFile( + join(workspace, '.adt/objects/CLAS/zcl_zzz_inexact.clas.adt.json'), + 'utf8', + ), + ).resolves.toContain('SOURCE_HISTORY_SCOPE_VERSION_MISSING'); }); it('materializes the exact subset only when partial mode explicitly opts in', async () => { diff --git a/packages/adt-mcp/README.md b/packages/adt-mcp/README.md index ae937bd92..f900f50ab 100644 --- a/packages/adt-mcp/README.md +++ b/packages/adt-mcp/README.md @@ -540,6 +540,23 @@ SAP-call counts. It never contains source bodies or credentials. --- +#### `flow_index_tr` + +Persist inventory and unresolved-boundary descriptors for an allowed local +workspace without reading or materializing source files. Unlike strict +`flow_checkout_tr`, this tool records inexact components as omissions so a +later exact checkout can retry them. + +| Parameter | Type | Description | +| --------------- | -------- | --------------------------------------------- | +| `transports` | string[] | Non-empty transport scope | +| `workspaceRoot` | string | Absolute directory within a server-owned root | + +The JSON result contains transport/object descriptors and omission diagnostics; +its source-call count is always zero. + +--- + ### ABAP Test Cockpit (ATC) #### `atc_run` @@ -707,6 +724,7 @@ bunx nx test adt-mcp # run integration tests with Vitest | `adt cts tr create` | `cts_create_transport` | ✅ | | `adt cts tr release` | `cts_release_transport` | ✅ | | `adt flow checkout tr` | `flow_checkout_tr` | ✅ | +| `adt flow index tr` | `flow_index_tr` | ✅ | | `adt ls` | — | 🔜 Future | | `adt cts search` | — | 🔜 Future | | `adt import package` | — | 🔜 Future | diff --git a/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts b/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts index bf6cbb4d1..7bbaab28f 100644 --- a/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts +++ b/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts @@ -15,13 +15,13 @@ import { sessionOrConnectionShape } from './shared-schemas'; import { resolveClient } from './session-helpers'; import { resolveFlowWorkspaceRoot } from '../flow-workspace'; -interface FlowMcpDependencies { +export interface FlowMcpDependencies { loadFlowConfig(root: string, context: ToolContext): Promise; getFormat(id: string): FormatPlugin | undefined; createService(client: AdtClient, format: FormatPlugin): AdtFlowService; } -const DEFAULT_DEPENDENCIES: FlowMcpDependencies = { +export const DEFAULT_FLOW_MCP_DEPENDENCIES: FlowMcpDependencies = { async loadFlowConfig(root, context) { if (context.flowConfig) return context.flowConfig; const loaded = await loadConfig({ cwd: root }); @@ -52,7 +52,7 @@ export function registerFlowCheckoutTrTool( ctx: ToolContext, overrides: Partial = {}, ): void { - const dependencies = { ...DEFAULT_DEPENDENCIES, ...overrides }; + const dependencies = { ...DEFAULT_FLOW_MCP_DEPENDENCIES, ...overrides }; server.tool( 'flow_checkout_tr', 'Reconcile a confined workspace to the exact base or head source boundary of one or more transports.', diff --git a/packages/adt-mcp/src/lib/tools/flow-index-tr.ts b/packages/adt-mcp/src/lib/tools/flow-index-tr.ts new file mode 100644 index 000000000..eab46a4e7 --- /dev/null +++ b/packages/adt-mcp/src/lib/tools/flow-index-tr.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { AdtFlowError } from '@abapify/adt-flow'; +import type { ToolContext } from '../types'; +import { sessionOrConnectionShape } from './shared-schemas'; +import { resolveClient } from './session-helpers'; +import { resolveFlowWorkspaceRoot } from '../flow-workspace'; +import { + DEFAULT_FLOW_MCP_DEPENDENCIES, + type FlowMcpDependencies, +} from './flow-checkout-tr'; + +/** + * Persist a transport's inventory and unresolved-boundary descriptors without + * materializing any source files into the workspace. + */ +export function registerFlowIndexTrTool( + server: McpServer, + ctx: ToolContext, + overrides: Partial = {}, +): void { + const dependencies = { ...DEFAULT_FLOW_MCP_DEPENDENCIES, ...overrides }; + server.tool( + 'flow_index_tr', + 'Persist a confined workspace transport inventory without materializing source files.', + { + ...sessionOrConnectionShape, + transports: z.array(z.string().trim().min(1)).min(1), + workspaceRoot: z + .string() + .min(1) + .describe('Absolute target directory within a server-owned root'), + }, + { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + async (args, extra) => { + try { + const root = await resolveFlowWorkspaceRoot( + args.workspaceRoot, + ctx.workspaceRoots, + ); + const config = await dependencies.loadFlowConfig(root, ctx); + const format = dependencies.getFormat(config.format.id); + if (!format) { + throw new AdtFlowError( + 'format_unsupported', + 'Configured format is not registered.', + ); + } + const revalidatedRoot = await resolveFlowWorkspaceRoot( + args.workspaceRoot, + ctx.workspaceRoots, + ); + if (revalidatedRoot !== root) { + throw new AdtFlowError( + 'workspace_root_changed', + 'Workspace root changed between configuration load and indexing.', + ); + } + const { client } = await resolveClient(ctx, args, extra ?? {}); + const result = await dependencies.createService(client, format).index({ + root, + transports: args.transports, + config, + }); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(result, null, 2) }, + ], + structuredContent: result as unknown as Record, + }; + } catch (error) { + const isFlowError = error instanceof AdtFlowError; + const code = isFlowError ? error.code : 'FLOW_INDEX_FAILED'; + const message = isFlowError + ? error.message + : 'Could not index the requested transport inventory.'; + const cause = error instanceof Error ? error.message : String(error); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: { + code, + message, + details: isFlowError + ? (error.details ?? { cause }) + : { cause }, + }, + }), + }, + ], + }; + } + }, + ); +} diff --git a/packages/adt-mcp/src/lib/tools/index.ts b/packages/adt-mcp/src/lib/tools/index.ts index 1d59c0a19..2faa3f457 100644 --- a/packages/adt-mcp/src/lib/tools/index.ts +++ b/packages/adt-mcp/src/lib/tools/index.ts @@ -125,6 +125,7 @@ import { registerListSourceVersionsTool } from './list-source-versions'; import { registerGetSourceVersionTool } from './get-source-version'; import { registerCtsTransportSourceManifestTool } from './cts-transport-source-manifest'; import { registerFlowCheckoutTrTool } from './flow-checkout-tr'; +import { registerFlowIndexTrTool } from './flow-index-tr'; export function registerTools(server: McpServer, ctx: ToolContext): void { // Session lifecycle tools (HTTP transport only) @@ -246,4 +247,5 @@ export function registerTools(server: McpServer, ctx: ToolContext): void { registerGetSourceVersionTool(server, ctx); registerCtsTransportSourceManifestTool(server, ctx); registerFlowCheckoutTrTool(server, ctx); + registerFlowIndexTrTool(server, ctx); } diff --git a/packages/adt-mcp/src/lib/tools/scope-catalogue.ts b/packages/adt-mcp/src/lib/tools/scope-catalogue.ts index 3a19172a6..7cd0703fa 100644 --- a/packages/adt-mcp/src/lib/tools/scope-catalogue.ts +++ b/packages/adt-mcp/src/lib/tools/scope-catalogue.ts @@ -181,6 +181,7 @@ export const MCP_TOOL_SCOPE_CATALOGUE: Readonly> = 'changeset_rollback', 'checkin', 'flow_checkout_tr', + 'flow_index_tr', 'clone_object', 'create_badi', 'create_bdef', diff --git a/packages/adt-mcp/tests/flow-index-tr.vitest.test.ts b/packages/adt-mcp/tests/flow-index-tr.vitest.test.ts new file mode 100644 index 000000000..04de1d5b3 --- /dev/null +++ b/packages/adt-mcp/tests/flow-index-tr.vitest.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'vitest'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AdtClient } from '@abapify/adt-client'; +import type { FormatPlugin } from '@abapify/adt-plugin'; +import type { ToolContext } from '../src/lib/types.js'; +import { registerFlowIndexTrTool } from '../src/lib/tools/flow-index-tr.js'; + +type ToolResult = { + isError?: boolean; + content: Array<{ type: 'text'; text: string }>; + structuredContent?: Record; +}; + +type Handler = ( + args: Record, + extra: { sessionId?: string }, +) => Promise; + +class CapturingServer { + handler?: Handler; + annotations?: Record; + + tool(...args: unknown[]): void { + this.annotations = args[3] as Record; + this.handler = args.at(-1) as Handler; + } +} + +const format = { + id: 'abapgit', + description: 'test', + supportedTypes: ['CLAS'], + getHandler: () => undefined, +} satisfies FormatPlugin; + +test('flow_index_tr delegates source-free indexing to the shared service', async () => { + const allowed = await realpath( + await mkdtemp(join(tmpdir(), 'adt-flow-index-mcp-')), + ); + const target = new CapturingServer(); + let indexInput: unknown; + const ctx = { + getClient: () => ({}) as AdtClient, + workspaceRoots: [allowed], + flowConfig: { format: { id: 'abapgit' } }, + } satisfies ToolContext; + registerFlowIndexTrTool(target as unknown as McpServer, ctx, { + getFormat: () => format, + createService: () => ({ + async checkout() { + throw new Error('checkout must not be called by flow_index_tr'); + }, + async index(input) { + indexInput = input; + return { + mode: 'head', + requestedTransports: ['DEVK900001'], + scopeTransports: ['DEVK900001'], + changed: [], + moved: [], + removed: [], + unchanged: [], + descriptors: ['.adt/tr/DEVK900001.json'], + skipped: [], + sapCalls: { manifest: 1, metadata: 0, source: 0 }, + fastPath: 'none', + }; + }, + }), + }); + + const result = await target.handler!( + { + baseUrl: 'https://example.invalid', + transports: ['DEVK900001'], + workspaceRoot: allowed, + }, + {}, + ); + + assert.notStrictEqual(result.isError, true); + assert.deepEqual(indexInput, { + root: allowed, + transports: ['DEVK900001'], + config: ctx.flowConfig, + }); + assert.equal(result.structuredContent?.sapCalls.source, 0); + assert.deepEqual(target.annotations, { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }); +}); diff --git a/packages/adt-mcp/vitest.config.ts b/packages/adt-mcp/vitest.config.ts index d0b991797..05860baa6 100644 --- a/packages/adt-mcp/vitest.config.ts +++ b/packages/adt-mcp/vitest.config.ts @@ -4,6 +4,9 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['tests/integration.test.ts'], + include: [ + 'tests/integration.test.ts', + 'tests/flow-index-tr.vitest.test.ts', + ], }, }); From 3d58774497bd6f1765447b5d51e04a1f02d33e8b Mon Sep 17 00:00:00 2001 From: Petr Plenkov <6381507+ThePlenkov@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:21:27 +0200 Subject: [PATCH 2/5] fix(flow): preserve index-only recovery invariants Reuse MCP transport execution, allow inventory-only formats, preserve exact descriptor links, and keep parity coverage in the active integration suite. --- packages/adt-flow/src/service.ts | 93 +++++++++--- packages/adt-flow/tests/service.test.ts | 51 +++++++ .../adt-mcp/src/lib/tools/flow-checkout-tr.ts | 125 +++------------- .../adt-mcp/src/lib/tools/flow-index-tr.ts | 76 ++-------- .../src/lib/tools/flow-transport-common.ts | 134 ++++++++++++++++++ .../tests/flow-index-tr.vitest.test.ts | 98 ------------- packages/adt-mcp/tests/integration.test.ts | 101 ++++++++++++- packages/adt-mcp/vitest.config.ts | 5 +- 8 files changed, 386 insertions(+), 297 deletions(-) create mode 100644 packages/adt-mcp/src/lib/tools/flow-transport-common.ts delete mode 100644 packages/adt-mcp/tests/flow-index-tr.vitest.test.ts diff --git a/packages/adt-flow/src/service.ts b/packages/adt-flow/src/service.ts index 0c3279f2e..af7f59014 100644 --- a/packages/adt-flow/src/service.ts +++ b/packages/adt-flow/src/service.ts @@ -889,7 +889,7 @@ export interface AdtFlowService { index(input: FlowIndexInput): Promise; } -interface CheckoutContext { +interface FlowContext { root: string; mode: 'base' | 'head'; partial: boolean; @@ -898,14 +898,20 @@ interface CheckoutContext { configDigest: string; formatDigest: string; dependencies: FlowCheckoutDependencies; - materialize: NonNullable; calls: { manifest: number; metadata: number; source: number }; } -function createCheckoutContext( - input: FlowCheckoutInput, +interface CheckoutContext extends FlowContext { + materialize: NonNullable; +} + +function createFlowContext( + input: Pick< + FlowCheckoutInput, + 'root' | 'transports' | 'mode' | 'partial' | 'config' + >, dependencies: FlowCheckoutDependencies, -): CheckoutContext { +): FlowContext { const parsed = flowConfigSchema.safeParse(input.config); if (!parsed.success) { throw new AdtFlowError( @@ -914,13 +920,10 @@ function createCheckoutContext( ); } const config = parsed.data; - const materialize = dependencies.format.materialize?.bind( - dependencies.format, - ); - if (config.format.id !== dependencies.format.id || !materialize) { + if (config.format.id !== dependencies.format.id) { throw new AdtFlowError( 'format_unsupported', - 'The selected format does not support flow materialization.', + 'The selected format is not registered for flow.', ); } return { @@ -936,11 +939,27 @@ function createCheckoutContext( supportedTypes: [...dependencies.format.supportedTypes].sort(), }), dependencies, - materialize, calls: { manifest: 0, metadata: 0, source: 0 }, }; } +function createCheckoutContext( + input: FlowCheckoutInput, + dependencies: FlowCheckoutDependencies, +): CheckoutContext { + const context = createFlowContext(input, dependencies); + const materialize = dependencies.format.materialize?.bind( + dependencies.format, + ); + if (!materialize) { + throw new AdtFlowError( + 'format_unsupported', + 'The selected format does not support flow materialization.', + ); + } + return { ...context, materialize }; +} + async function tryExactHeadFastPath( ctx: CheckoutContext, ): Promise { @@ -985,7 +1004,7 @@ interface ManifestContext { } function buildManifestRequestOptions( - config: CheckoutContext['config'], + config: FlowContext['config'], ): BuildManifestOptions { const options: BuildManifestOptions = { concurrency: config.concurrency?.metadata ?? DEFAULT_METADATA_CONCURRENCY, @@ -1026,7 +1045,7 @@ function inexactEntries( async function filterSkippedByApplicationComponent( entries: readonly TransportSourceManifestEntry[], - ctx: CheckoutContext, + ctx: FlowContext, limiter: Limiter, toSkipped: (entry: TransportSourceManifestEntry) => FlowSkippedObject, ): Promise { @@ -1085,7 +1104,7 @@ async function filterSkippedByApplicationComponent( async function skippedInexactEntries( entries: readonly TransportSourceManifestEntry[], - ctx: CheckoutContext, + ctx: FlowContext, limiter: Limiter, hasApplicationComponentFilter: boolean, ): Promise { @@ -1107,7 +1126,7 @@ async function skippedInexactEntries( async function unsupportedEntries( entries: readonly TransportSourceManifestEntry[], - ctx: CheckoutContext, + ctx: FlowContext, limiter: Limiter, hasApplicationComponentFilter: boolean, ): Promise { @@ -1134,7 +1153,7 @@ async function unsupportedEntries( } async function buildManifestAndGroups( - ctx: CheckoutContext, + ctx: FlowContext, ): Promise { ctx.calls.manifest += 1; const manifest = await ctx.dependencies.buildManifest( @@ -1198,7 +1217,7 @@ type PendingOwnership = Map< >; async function validateIndexedOwnership( - ctx: CheckoutContext, + ctx: FlowContext, identity: FlowObjectIdentity, descriptor: ObjectDescriptor, ): Promise { @@ -1329,7 +1348,7 @@ async function processAllGroups( } async function addTransportDescriptors( - ctx: CheckoutContext, + ctx: FlowContext, manifest: TransportSourceManifest, accum: CheckoutAccumulator, incomplete: boolean, @@ -1408,7 +1427,7 @@ function skippedEntryMatches( * with the exact reason that prevented materialization. */ async function addOmittedObjectDescriptors( - ctx: CheckoutContext, + ctx: FlowContext, manifest: TransportSourceManifest, skipped: FlowCheckoutResult['skipped'], accum: CheckoutAccumulator, @@ -1486,8 +1505,39 @@ async function addOmittedObjectDescriptors( } } +/** + * Keep links to exact, already-materialized objects when refreshing a + * transport inventory. Indexing must not rewrite their source or descriptor, + * but the transport descriptor still needs them for a later exact-head reuse. + */ +async function addExistingObjectDescriptors( + ctx: FlowContext, + groups: ManifestContext['groups'], + accum: CheckoutAccumulator, +): Promise { + await Promise.all( + groups.map(async ({ identity }) => { + const descriptorPath = objectDescriptorPath(identity); + if (accum.descriptorPaths.includes(descriptorPath)) return; + const descriptor = await readDescriptor( + ctx.root, + descriptorPath, + objectDescriptorSchema, + ); + if ( + descriptor?.state === 'present' && + descriptor.identity.canonical === identity.canonical && + descriptor.configDigest === ctx.configDigest && + descriptor.formatDigest === ctx.formatDigest + ) { + accum.descriptorPaths.push(descriptorPath); + } + }), + ); +} + function buildCheckoutResult( - ctx: CheckoutContext, + ctx: FlowContext, manifest: TransportSourceManifest, skipped: FlowCheckoutResult['skipped'], plan: RepositoryPlan, @@ -1575,7 +1625,7 @@ async function indexFlow( ): Promise { // Reuse partial manifest classification so inexact entries become durable // omissions, but deliberately skip every source/materialization path. - const ctx = createCheckoutContext( + const ctx = createFlowContext( { ...input, mode: 'head', partial: true }, dependencies, ); @@ -1593,6 +1643,7 @@ async function indexFlow( manifestContext.skipped, indexed, ); + await addExistingObjectDescriptors(ctx, manifestContext.groups, indexed); await addTransportDescriptors(ctx, manifestContext.manifest, indexed, true); indexed.desired.sort((left, right) => compareStrings(left.path, right.path)); const plan = await planRepositoryChanges( diff --git a/packages/adt-flow/tests/service.test.ts b/packages/adt-flow/tests/service.test.ts index 4cda7f522..fb2675e0b 100644 --- a/packages/adt-flow/tests/service.test.ts +++ b/packages/adt-flow/tests/service.test.ts @@ -769,6 +769,57 @@ describe('transport checkout', () => { ).resolves.toContain('SOURCE_HISTORY_SCOPE_VERSION_MISSING'); }); + it('indexes inventory without requiring a materializing format plugin', async () => { + const workspace = await root(); + const ports = dependencies(() => + manifest('modified', version('before'), version('after')), + ); + ports.format = { ...format, materialize: undefined }; + + await expect( + createAdtFlowService(ports).index({ + root: workspace, + transports: ['DEVK900001'], + config, + }), + ).resolves.toMatchObject({ + descriptors: ['.adt/tr/DEVK900001.json'], + sapCalls: { source: 0 }, + }); + expect(ports.readSource).not.toHaveBeenCalled(); + expect(ports.loadObject).not.toHaveBeenCalled(); + }); + + it('retains existing exact object descriptors in a refreshed inventory', async () => { + const workspace = await root(); + const ports = dependencies(() => + manifest('modified', version('before'), version('after')), + ); + const flow = createAdtFlowService(ports); + await flow.checkout({ + root: workspace, + transports: ['DEVK900001'], + config, + }); + ports.readSource.mockClear(); + ports.loadObject.mockClear(); + + await flow.index({ + root: workspace, + transports: ['DEVK900001'], + config, + }); + + const transport = JSON.parse( + await readFile(join(workspace, '.adt/tr/DEVK900001.json'), 'utf8'), + ) as { objects: string[] }; + expect(transport.objects).toEqual([ + '.adt/objects/CLAS/zcl_sample.clas.adt.json', + ]); + expect(ports.readSource).not.toHaveBeenCalled(); + expect(ports.loadObject).not.toHaveBeenCalled(); + }); + it('materializes the exact subset only when partial mode explicitly opts in', async () => { const workspace = await root(); const current = manifest('modified', version('before'), version('after')); diff --git a/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts b/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts index 7bbaab28f..54f7f0ae5 100644 --- a/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts +++ b/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts @@ -1,51 +1,14 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { - AdtFlowError, - createAdtFlowDependencies, - createAdtFlowService, - flowConfigSchema, - type AdtFlowService, -} from '@abapify/adt-flow'; -import { loadConfig, type FlowConfig } from '@abapify/adt-config'; -import { getFormatPlugin, type FormatPlugin } from '@abapify/adt-plugin'; -import type { AdtClient } from '@abapify/adt-client'; import type { ToolContext } from '../types'; import { sessionOrConnectionShape } from './shared-schemas'; -import { resolveClient } from './session-helpers'; -import { resolveFlowWorkspaceRoot } from '../flow-workspace'; - -export interface FlowMcpDependencies { - loadFlowConfig(root: string, context: ToolContext): Promise; - getFormat(id: string): FormatPlugin | undefined; - createService(client: AdtClient, format: FormatPlugin): AdtFlowService; -} +import { + DEFAULT_FLOW_MCP_DEPENDENCIES, + runFlowTransportTool, + type FlowMcpDependencies, +} from './flow-transport-common'; -export const DEFAULT_FLOW_MCP_DEPENDENCIES: FlowMcpDependencies = { - async loadFlowConfig(root, context) { - if (context.flowConfig) return context.flowConfig; - const loaded = await loadConfig({ cwd: root }); - const flowValue = loaded.raw.flow; - if (flowValue === undefined) { - throw new AdtFlowError( - 'configuration_invalid', - 'Flow configuration is unavailable in this context.', - ); - } - try { - return flowConfigSchema.parse(flowValue); - } catch (error) { - throw new AdtFlowError( - 'configuration_invalid', - 'Flow configuration is invalid.', - { cause: String(error) }, - ); - } - }, - getFormat: getFormatPlugin, - createService: (client, format) => - createAdtFlowService(createAdtFlowDependencies(client, format)), -}; +export { type FlowMcpDependencies } from './flow-transport-common'; export function registerFlowCheckoutTrTool( server: McpServer, @@ -71,70 +34,18 @@ export function registerFlowCheckoutTrTool( idempotentHint: true, openWorldHint: true, }, - async (args, extra) => { - try { - const root = await resolveFlowWorkspaceRoot( - args.workspaceRoot, - ctx.workspaceRoots, - ); - const config = await dependencies.loadFlowConfig(root, ctx); - const format = dependencies.getFormat(config.format.id); - if (!format) { - throw new AdtFlowError( - 'format_unsupported', - 'Configured format is not registered.', - ); - } - const revalidatedRoot = await resolveFlowWorkspaceRoot( - args.workspaceRoot, - ctx.workspaceRoots, - ); - if (revalidatedRoot !== root) { - throw new AdtFlowError( - 'workspace_root_changed', - 'Workspace root changed between configuration load and checkout.', - ); - } - const { client } = await resolveClient(ctx, args, extra ?? {}); - const result = await dependencies - .createService(client, format) - .checkout({ - root, - transports: args.transports, + (args, extra) => + runFlowTransportTool(ctx, dependencies, args, extra ?? {}, { + rootChangedMessage: + 'Workspace root changed between configuration load and checkout.', + failureCode: 'FLOW_CHECKOUT_FAILED', + failureMessage: + 'Could not materialize the requested transport boundary.', + run: (service, input) => + service.checkout({ + ...input, mode: args.base ? 'base' : 'head', - config, - }); - return { - content: [ - { type: 'text' as const, text: JSON.stringify(result, null, 2) }, - ], - structuredContent: result as unknown as Record, - }; - } catch (error) { - const isFlowError = error instanceof AdtFlowError; - const code = isFlowError ? error.code : 'FLOW_CHECKOUT_FAILED'; - const message = isFlowError - ? error.message - : 'Could not materialize the requested transport boundary.'; - const cause = error instanceof Error ? error.message : String(error); - return { - isError: true, - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - error: { - code, - message, - details: isFlowError - ? (error.details ?? { cause }) - : { cause }, - }, - }), - }, - ], - }; - } - }, + }), + }), ); } diff --git a/packages/adt-mcp/src/lib/tools/flow-index-tr.ts b/packages/adt-mcp/src/lib/tools/flow-index-tr.ts index eab46a4e7..7e8bdc7c4 100644 --- a/packages/adt-mcp/src/lib/tools/flow-index-tr.ts +++ b/packages/adt-mcp/src/lib/tools/flow-index-tr.ts @@ -1,14 +1,12 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { AdtFlowError } from '@abapify/adt-flow'; import type { ToolContext } from '../types'; import { sessionOrConnectionShape } from './shared-schemas'; -import { resolveClient } from './session-helpers'; -import { resolveFlowWorkspaceRoot } from '../flow-workspace'; import { DEFAULT_FLOW_MCP_DEPENDENCIES, + runFlowTransportTool, type FlowMcpDependencies, -} from './flow-checkout-tr'; +} from './flow-transport-common'; /** * Persist a transport's inventory and unresolved-boundary descriptors without @@ -37,67 +35,13 @@ export function registerFlowIndexTrTool( idempotentHint: true, openWorldHint: true, }, - async (args, extra) => { - try { - const root = await resolveFlowWorkspaceRoot( - args.workspaceRoot, - ctx.workspaceRoots, - ); - const config = await dependencies.loadFlowConfig(root, ctx); - const format = dependencies.getFormat(config.format.id); - if (!format) { - throw new AdtFlowError( - 'format_unsupported', - 'Configured format is not registered.', - ); - } - const revalidatedRoot = await resolveFlowWorkspaceRoot( - args.workspaceRoot, - ctx.workspaceRoots, - ); - if (revalidatedRoot !== root) { - throw new AdtFlowError( - 'workspace_root_changed', - 'Workspace root changed between configuration load and indexing.', - ); - } - const { client } = await resolveClient(ctx, args, extra ?? {}); - const result = await dependencies.createService(client, format).index({ - root, - transports: args.transports, - config, - }); - return { - content: [ - { type: 'text' as const, text: JSON.stringify(result, null, 2) }, - ], - structuredContent: result as unknown as Record, - }; - } catch (error) { - const isFlowError = error instanceof AdtFlowError; - const code = isFlowError ? error.code : 'FLOW_INDEX_FAILED'; - const message = isFlowError - ? error.message - : 'Could not index the requested transport inventory.'; - const cause = error instanceof Error ? error.message : String(error); - return { - isError: true, - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - error: { - code, - message, - details: isFlowError - ? (error.details ?? { cause }) - : { cause }, - }, - }), - }, - ], - }; - } - }, + (args, extra) => + runFlowTransportTool(ctx, dependencies, args, extra ?? {}, { + rootChangedMessage: + 'Workspace root changed between configuration load and indexing.', + failureCode: 'FLOW_INDEX_FAILED', + failureMessage: 'Could not index the requested transport inventory.', + run: (service, input) => service.index(input), + }), ); } diff --git a/packages/adt-mcp/src/lib/tools/flow-transport-common.ts b/packages/adt-mcp/src/lib/tools/flow-transport-common.ts new file mode 100644 index 000000000..110249b58 --- /dev/null +++ b/packages/adt-mcp/src/lib/tools/flow-transport-common.ts @@ -0,0 +1,134 @@ +import { + AdtFlowError, + createAdtFlowDependencies, + createAdtFlowService, + flowConfigSchema, + type AdtFlowService, + type FlowCheckoutResult, + type FlowIndexInput, +} from '@abapify/adt-flow'; +import { loadConfig, type FlowConfig } from '@abapify/adt-config'; +import { getFormatPlugin, type FormatPlugin } from '@abapify/adt-plugin'; +import type { AdtClient } from '@abapify/adt-client'; +import type { ToolContext } from '../types'; +import { resolveClient } from './session-helpers'; +import { resolveFlowWorkspaceRoot } from '../flow-workspace'; + +export interface FlowMcpDependencies { + loadFlowConfig(root: string, context: ToolContext): Promise; + getFormat(id: string): FormatPlugin | undefined; + createService(client: AdtClient, format: FormatPlugin): AdtFlowService; +} + +export const DEFAULT_FLOW_MCP_DEPENDENCIES: FlowMcpDependencies = { + async loadFlowConfig(root, context) { + if (context.flowConfig) return context.flowConfig; + const loaded = await loadConfig({ cwd: root }); + const flowValue = loaded.raw.flow; + if (flowValue === undefined) { + throw new AdtFlowError( + 'configuration_invalid', + 'Flow configuration is unavailable in this context.', + ); + } + try { + return flowConfigSchema.parse(flowValue); + } catch (error) { + throw new AdtFlowError( + 'configuration_invalid', + 'Flow configuration is invalid.', + { cause: String(error) }, + ); + } + }, + getFormat: getFormatPlugin, + createService: (client, format) => + createAdtFlowService(createAdtFlowDependencies(client, format)), +}; + +type FlowToolArgs = { + transports: string[]; + workspaceRoot: string; +}; + +type FlowToolResult = { + isError?: boolean; + content: Array<{ type: 'text'; text: string }>; + structuredContent?: Record; +}; + +export async function runFlowTransportTool( + ctx: ToolContext, + dependencies: FlowMcpDependencies, + args: FlowToolArgs, + extra: { sessionId?: string }, + options: { + rootChangedMessage: string; + failureCode: string; + failureMessage: string; + run( + service: AdtFlowService, + input: FlowIndexInput, + ): Promise; + }, +): Promise { + try { + const root = await resolveFlowWorkspaceRoot( + args.workspaceRoot, + ctx.workspaceRoots, + ); + const config = await dependencies.loadFlowConfig(root, ctx); + const format = dependencies.getFormat(config.format.id); + if (!format) { + throw new AdtFlowError( + 'format_unsupported', + 'Configured format is not registered.', + ); + } + const revalidatedRoot = await resolveFlowWorkspaceRoot( + args.workspaceRoot, + ctx.workspaceRoots, + ); + if (revalidatedRoot !== root) { + throw new AdtFlowError( + 'workspace_root_changed', + options.rootChangedMessage, + ); + } + const { client } = await resolveClient(ctx, args, extra); + const result = await options.run( + dependencies.createService(client, format), + { + root, + transports: args.transports, + config, + }, + ); + return { + content: [ + { type: 'text' as const, text: JSON.stringify(result, null, 2) }, + ], + structuredContent: result as unknown as Record, + }; + } catch (error) { + const isFlowError = error instanceof AdtFlowError; + const code = isFlowError ? error.code : options.failureCode; + const message = isFlowError ? error.message : options.failureMessage; + const cause = error instanceof Error ? error.message : String(error); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: { + code, + message, + details: isFlowError ? (error.details ?? { cause }) : { cause }, + }, + }), + }, + ], + }; + } +} diff --git a/packages/adt-mcp/tests/flow-index-tr.vitest.test.ts b/packages/adt-mcp/tests/flow-index-tr.vitest.test.ts deleted file mode 100644 index 04de1d5b3..000000000 --- a/packages/adt-mcp/tests/flow-index-tr.vitest.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, realpath } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'vitest'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { AdtClient } from '@abapify/adt-client'; -import type { FormatPlugin } from '@abapify/adt-plugin'; -import type { ToolContext } from '../src/lib/types.js'; -import { registerFlowIndexTrTool } from '../src/lib/tools/flow-index-tr.js'; - -type ToolResult = { - isError?: boolean; - content: Array<{ type: 'text'; text: string }>; - structuredContent?: Record; -}; - -type Handler = ( - args: Record, - extra: { sessionId?: string }, -) => Promise; - -class CapturingServer { - handler?: Handler; - annotations?: Record; - - tool(...args: unknown[]): void { - this.annotations = args[3] as Record; - this.handler = args.at(-1) as Handler; - } -} - -const format = { - id: 'abapgit', - description: 'test', - supportedTypes: ['CLAS'], - getHandler: () => undefined, -} satisfies FormatPlugin; - -test('flow_index_tr delegates source-free indexing to the shared service', async () => { - const allowed = await realpath( - await mkdtemp(join(tmpdir(), 'adt-flow-index-mcp-')), - ); - const target = new CapturingServer(); - let indexInput: unknown; - const ctx = { - getClient: () => ({}) as AdtClient, - workspaceRoots: [allowed], - flowConfig: { format: { id: 'abapgit' } }, - } satisfies ToolContext; - registerFlowIndexTrTool(target as unknown as McpServer, ctx, { - getFormat: () => format, - createService: () => ({ - async checkout() { - throw new Error('checkout must not be called by flow_index_tr'); - }, - async index(input) { - indexInput = input; - return { - mode: 'head', - requestedTransports: ['DEVK900001'], - scopeTransports: ['DEVK900001'], - changed: [], - moved: [], - removed: [], - unchanged: [], - descriptors: ['.adt/tr/DEVK900001.json'], - skipped: [], - sapCalls: { manifest: 1, metadata: 0, source: 0 }, - fastPath: 'none', - }; - }, - }), - }); - - const result = await target.handler!( - { - baseUrl: 'https://example.invalid', - transports: ['DEVK900001'], - workspaceRoot: allowed, - }, - {}, - ); - - assert.notStrictEqual(result.isError, true); - assert.deepEqual(indexInput, { - root: allowed, - transports: ['DEVK900001'], - config: ctx.flowConfig, - }); - assert.equal(result.structuredContent?.sapCalls.source, 0); - assert.deepEqual(target.annotations, { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: true, - openWorldHint: true, - }); -}); diff --git a/packages/adt-mcp/tests/integration.test.ts b/packages/adt-mcp/tests/integration.test.ts index 13fbeaa7d..934c06c83 100644 --- a/packages/adt-mcp/tests/integration.test.ts +++ b/packages/adt-mcp/tests/integration.test.ts @@ -10,18 +10,50 @@ import { describe, it, beforeAll, afterAll } from 'vitest'; import assert from 'node:assert'; import { randomBytes } from 'node:crypto'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { createMcpServer } from '../src/lib/server'; import { createMockAdtServer, type MockAdtServer } from '@abapify/adt-fixtures'; import { createAdtClient, type AdtClient } from '@abapify/adt-client'; -import type { ConnectionParams } from '../src/lib/types'; +import type { FormatPlugin } from '@abapify/adt-plugin'; +import type { ConnectionParams, ToolContext } from '../src/lib/types'; +import { registerFlowIndexTrTool } from '../src/lib/tools/flow-index-tr'; let mockAdt: MockAdtServer; let mockPort: number; let client: Client; let boundedSourceReadCalls = 0; +type FlowToolResult = { + isError?: boolean; + content: Array<{ type: 'text'; text: string }>; + structuredContent?: Record; +}; + +class CapturingServer { + handler?: ( + args: Record, + extra: { sessionId?: string }, + ) => Promise; + annotations?: Record; + + tool(...args: unknown[]): void { + this.annotations = args[3] as Record; + this.handler = args.at(-1) as CapturingServer['handler']; + } +} + +const flowFormat = { + id: 'abapgit', + description: 'test', + supportedTypes: ['CLAS'], + getHandler: () => undefined, +} satisfies FormatPlugin; + /** * Helper – call a tool and return the first text content block parsed as JSON. */ @@ -1478,10 +1510,77 @@ describe('adt-mcp integration tests', () => { 'get_structure', 'get_cds_ddl', 'get_cds_dcl', + 'flow_index_tr', ]; for (const name of expected) { assert.ok(names.has(name), `tool "${name}" should be listed`); } }); }); + + describe('flow index tool', () => { + it('delegates source-free indexing to the shared flow service', async () => { + const allowed = await realpath( + await mkdtemp(join(tmpdir(), 'adt-flow-index-mcp-')), + ); + const target = new CapturingServer(); + let indexInput: unknown; + const ctx = { + getClient: () => ({}) as AdtClient, + workspaceRoots: [allowed], + flowConfig: { format: { id: 'abapgit' } }, + } satisfies ToolContext; + registerFlowIndexTrTool(target as unknown as McpServer, ctx, { + getFormat: () => flowFormat, + createService: () => ({ + async checkout() { + throw new Error('checkout must not be called by flow_index_tr'); + }, + async index(input) { + indexInput = input; + return { + mode: 'head', + requestedTransports: ['DEVK900001'], + scopeTransports: ['DEVK900001'], + changed: [], + moved: [], + removed: [], + unchanged: [], + descriptors: ['.adt/tr/DEVK900001.json'], + skipped: [], + sapCalls: { manifest: 1, metadata: 0, source: 0 }, + fastPath: 'none', + }; + }, + }), + }); + + try { + const result = await target.handler!( + { + baseUrl: 'https://example.invalid', + transports: ['DEVK900001'], + workspaceRoot: allowed, + }, + {}, + ); + + assert.notStrictEqual(result.isError, true); + assert.deepStrictEqual(indexInput, { + root: allowed, + transports: ['DEVK900001'], + config: ctx.flowConfig, + }); + assert.strictEqual(result.structuredContent?.sapCalls.source, 0); + assert.deepStrictEqual(target.annotations, { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }); + } finally { + await rm(allowed, { recursive: true, force: true }); + } + }); + }); }); diff --git a/packages/adt-mcp/vitest.config.ts b/packages/adt-mcp/vitest.config.ts index 05860baa6..d0b991797 100644 --- a/packages/adt-mcp/vitest.config.ts +++ b/packages/adt-mcp/vitest.config.ts @@ -4,9 +4,6 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: [ - 'tests/integration.test.ts', - 'tests/flow-index-tr.vitest.test.ts', - ], + include: ['tests/integration.test.ts'], }, }); From d94da5d01805c36624a9eaf2113d7bb7fcca74c9 Mon Sep 17 00:00:00 2001 From: Petr Plenkov <6381507+ThePlenkov@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:24:46 +0200 Subject: [PATCH 3/5] refactor(mcp): group transport tool execution input Use a typed request object for the shared transport executor while preserving checkout and index behaviour. --- .../adt-mcp/src/lib/tools/flow-checkout-tr.ts | 28 +++++++++++-------- .../adt-mcp/src/lib/tools/flow-index-tr.ts | 18 ++++++++---- .../src/lib/tools/flow-transport-common.ts | 22 ++++++++++----- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts b/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts index 54f7f0ae5..706cb6f49 100644 --- a/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts +++ b/packages/adt-mcp/src/lib/tools/flow-checkout-tr.ts @@ -35,17 +35,23 @@ export function registerFlowCheckoutTrTool( openWorldHint: true, }, (args, extra) => - runFlowTransportTool(ctx, dependencies, args, extra ?? {}, { - rootChangedMessage: - 'Workspace root changed between configuration load and checkout.', - failureCode: 'FLOW_CHECKOUT_FAILED', - failureMessage: - 'Could not materialize the requested transport boundary.', - run: (service, input) => - service.checkout({ - ...input, - mode: args.base ? 'base' : 'head', - }), + runFlowTransportTool({ + ctx, + dependencies, + args, + extra: extra ?? {}, + options: { + rootChangedMessage: + 'Workspace root changed between configuration load and checkout.', + failureCode: 'FLOW_CHECKOUT_FAILED', + failureMessage: + 'Could not materialize the requested transport boundary.', + run: (service, input) => + service.checkout({ + ...input, + mode: args.base ? 'base' : 'head', + }), + }, }), ); } diff --git a/packages/adt-mcp/src/lib/tools/flow-index-tr.ts b/packages/adt-mcp/src/lib/tools/flow-index-tr.ts index 7e8bdc7c4..a37a562e8 100644 --- a/packages/adt-mcp/src/lib/tools/flow-index-tr.ts +++ b/packages/adt-mcp/src/lib/tools/flow-index-tr.ts @@ -36,12 +36,18 @@ export function registerFlowIndexTrTool( openWorldHint: true, }, (args, extra) => - runFlowTransportTool(ctx, dependencies, args, extra ?? {}, { - rootChangedMessage: - 'Workspace root changed between configuration load and indexing.', - failureCode: 'FLOW_INDEX_FAILED', - failureMessage: 'Could not index the requested transport inventory.', - run: (service, input) => service.index(input), + runFlowTransportTool({ + ctx, + dependencies, + args, + extra: extra ?? {}, + options: { + rootChangedMessage: + 'Workspace root changed between configuration load and indexing.', + failureCode: 'FLOW_INDEX_FAILED', + failureMessage: 'Could not index the requested transport inventory.', + run: (service, input) => service.index(input), + }, }), ); } diff --git a/packages/adt-mcp/src/lib/tools/flow-transport-common.ts b/packages/adt-mcp/src/lib/tools/flow-transport-common.ts index 110249b58..fb9496cf3 100644 --- a/packages/adt-mcp/src/lib/tools/flow-transport-common.ts +++ b/packages/adt-mcp/src/lib/tools/flow-transport-common.ts @@ -57,11 +57,11 @@ type FlowToolResult = { structuredContent?: Record; }; -export async function runFlowTransportTool( - ctx: ToolContext, - dependencies: FlowMcpDependencies, - args: FlowToolArgs, - extra: { sessionId?: string }, +interface FlowTransportToolRequest { + ctx: ToolContext; + dependencies: FlowMcpDependencies; + args: FlowToolArgs; + extra: { sessionId?: string }; options: { rootChangedMessage: string; failureCode: string; @@ -70,8 +70,16 @@ export async function runFlowTransportTool( service: AdtFlowService, input: FlowIndexInput, ): Promise; - }, -): Promise { + }; +} + +export async function runFlowTransportTool({ + ctx, + dependencies, + args, + extra, + options, +}: FlowTransportToolRequest): Promise { try { const root = await resolveFlowWorkspaceRoot( args.workspaceRoot, From 060a3891948385a9004dd6207be6b18d22c9fe6a Mon Sep 17 00:00:00 2001 From: Petr Plenkov <6381507+ThePlenkov@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:29:49 +0200 Subject: [PATCH 4/5] fix(mcp): redact raw flow failure causes Do not expose adapter causes or rollback diagnostics from transport flow tools. --- .../src/lib/tools/flow-transport-common.ts | 16 ++++++- packages/adt-mcp/tests/integration.test.ts | 45 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/adt-mcp/src/lib/tools/flow-transport-common.ts b/packages/adt-mcp/src/lib/tools/flow-transport-common.ts index fb9496cf3..ee3a8a0e1 100644 --- a/packages/adt-mcp/src/lib/tools/flow-transport-common.ts +++ b/packages/adt-mcp/src/lib/tools/flow-transport-common.ts @@ -57,6 +57,18 @@ type FlowToolResult = { structuredContent?: Record; }; +function safeFlowErrorDetails( + error: AdtFlowError, +): Record | undefined { + if (!error.details) return undefined; + const details = Object.fromEntries( + Object.entries(error.details).filter( + ([key]) => key !== 'cause' && key !== 'rollback', + ), + ); + return Object.keys(details).length > 0 ? details : undefined; +} + interface FlowTransportToolRequest { ctx: ToolContext; dependencies: FlowMcpDependencies; @@ -122,7 +134,7 @@ export async function runFlowTransportTool({ const isFlowError = error instanceof AdtFlowError; const code = isFlowError ? error.code : options.failureCode; const message = isFlowError ? error.message : options.failureMessage; - const cause = error instanceof Error ? error.message : String(error); + const details = isFlowError ? safeFlowErrorDetails(error) : undefined; return { isError: true, content: [ @@ -132,7 +144,7 @@ export async function runFlowTransportTool({ error: { code, message, - details: isFlowError ? (error.details ?? { cause }) : { cause }, + ...(details ? { details } : {}), }, }), }, diff --git a/packages/adt-mcp/tests/integration.test.ts b/packages/adt-mcp/tests/integration.test.ts index 934c06c83..bb0fe45e4 100644 --- a/packages/adt-mcp/tests/integration.test.ts +++ b/packages/adt-mcp/tests/integration.test.ts @@ -19,6 +19,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { createMcpServer } from '../src/lib/server'; import { createMockAdtServer, type MockAdtServer } from '@abapify/adt-fixtures'; import { createAdtClient, type AdtClient } from '@abapify/adt-client'; +import { AdtFlowError } from '@abapify/adt-flow'; import type { FormatPlugin } from '@abapify/adt-plugin'; import type { ConnectionParams, ToolContext } from '../src/lib/types'; import { registerFlowIndexTrTool } from '../src/lib/tools/flow-index-tr'; @@ -1582,5 +1583,49 @@ describe('adt-mcp integration tests', () => { await rm(allowed, { recursive: true, force: true }); } }); + + it('does not expose raw failure causes from the flow service', async () => { + const allowed = await realpath( + await mkdtemp(join(tmpdir(), 'adt-flow-index-error-')), + ); + const target = new CapturingServer(); + const ctx = { + getClient: () => ({}) as AdtClient, + workspaceRoots: [allowed], + flowConfig: { format: { id: 'abapgit' } }, + } satisfies ToolContext; + registerFlowIndexTrTool(target as unknown as McpServer, ctx, { + getFormat: () => flowFormat, + createService: () => ({ + async checkout() { + throw new Error('checkout must not be called by flow_index_tr'); + }, + async index() { + throw new AdtFlowError('apply_failed', 'Index apply failed.', { + cause: 'private adapter detail', + rollback: 'private rollback detail', + path: '.adt/tr/DEVK900001.json', + }); + }, + }), + }); + + try { + const result = await target.handler!( + { + baseUrl: 'https://example.invalid', + transports: ['DEVK900001'], + workspaceRoot: allowed, + }, + {}, + ); + const body = result.content[0]?.text ?? ''; + assert.strictEqual(result.isError, true); + assert.match(body, /\.adt\/tr\/DEVK900001\.json/u); + assert.doesNotMatch(body, /private adapter|private rollback/u); + } finally { + await rm(allowed, { recursive: true, force: true }); + } + }); }); }); From 7daba050dabd1cb8dd52b898677a53a4185ba1ae Mon Sep 17 00:00:00 2001 From: Petr Plenkov <6381507+ThePlenkov@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:56:01 +0200 Subject: [PATCH 5/5] feat(flow): index manifest-inexact checkout --- packages/adt-flow/README.md | 5 +++ packages/adt-flow/src/commands/flow.ts | 6 +++ packages/adt-flow/src/service.ts | 14 +++++- packages/adt-flow/src/types.ts | 5 +++ packages/adt-flow/tests/flow-command.test.ts | 18 ++++++++ packages/adt-flow/tests/service.test.ts | 45 ++++++++++++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/adt-flow/README.md b/packages/adt-flow/README.md index f6986c873..46ff11ac1 100644 --- a/packages/adt-flow/README.md +++ b/packages/adt-flow/README.md @@ -41,6 +41,11 @@ separate explicit opt-in that materializes only exact objects. `index` never materializes source; it retains the complete transport inventory and records every unresolved component as an `omitted` descriptor for a later retry. +Automation that must preserve an inexact transport for a later retry can opt +into `adt flow checkout tr --index-on-inexact`. Only the typed +`manifest_inexact` outcome falls back to source-free indexing; every other +checkout error remains fail-closed. + ```typescript import { createAdtFlowService, diff --git a/packages/adt-flow/src/commands/flow.ts b/packages/adt-flow/src/commands/flow.ts index 474b2719d..4320c3d4a 100644 --- a/packages/adt-flow/src/commands/flow.ts +++ b/packages/adt-flow/src/commands/flow.ts @@ -263,6 +263,11 @@ function checkoutTrCommand( description: 'Write skipped-object JSON after a successful partial checkout', }, + { + flags: '--index-on-inexact', + description: + 'Persist source-free inventory when an exact source boundary is unavailable', + }, ], async execute(args, ctx) { if (!ctx.getAdtClient) { @@ -306,6 +311,7 @@ function checkoutTrCommand( transports: transports(args['transport']), mode: args['base'] === true ? 'base' : 'head', partial: args['partial'] === true, + ...(args['indexOnInexact'] === true ? { indexOnInexact: true } : {}), config, }); if (report) await writePartialReport(report, realRoot, result); diff --git a/packages/adt-flow/src/service.ts b/packages/adt-flow/src/service.ts index af7f59014..d022d4c88 100644 --- a/packages/adt-flow/src/service.ts +++ b/packages/adt-flow/src/service.ts @@ -1577,7 +1577,19 @@ async function checkoutFlow( const fast = await tryExactHeadFastPath(ctx); if (fast) return fast; - const manifestContext = await buildManifestAndGroups(ctx); + let manifestContext: ManifestContext; + try { + manifestContext = await buildManifestAndGroups(ctx); + } catch (error) { + if ( + input.indexOnInexact === true && + error instanceof AdtFlowError && + error.code === 'manifest_inexact' + ) { + return indexFlow(input, dependencies); + } + throw error; + } const pendingOwnership = await buildPendingOwnership( ctx, manifestContext.groups, diff --git a/packages/adt-flow/src/types.ts b/packages/adt-flow/src/types.ts index 5875a167c..56e70c47c 100644 --- a/packages/adt-flow/src/types.ts +++ b/packages/adt-flow/src/types.ts @@ -65,6 +65,11 @@ export interface FlowCheckoutInput { * explicit caller opt-in; ordinary checkout remains fail-closed. */ partial?: boolean; + /** + * Persist only source-free transport inventory when manifest exactness is + * unavailable. Other checkout failures remain fail-closed. + */ + indexOnInexact?: boolean; config: FlowConfig; } diff --git a/packages/adt-flow/tests/flow-command.test.ts b/packages/adt-flow/tests/flow-command.test.ts index 22b047af8..ff7cd7f90 100644 --- a/packages/adt-flow/tests/flow-command.test.ts +++ b/packages/adt-flow/tests/flow-command.test.ts @@ -169,6 +169,24 @@ describe('flow CLI command', () => { expect(warn).toHaveBeenCalledTimes(1); }); + it('forwards the explicit index-on-inexact opt-in to checkout', async () => { + const checkout = makeCheckout(); + const command = createFlowCommand({ + getFormat: vi.fn(() => format), + createService: vi.fn(() => ({ checkout, index: makeIndex() })), + }); + const ctx = makeContext('/workspace'); + + await leaf(command).execute?.( + { transport: 'DEVK900001', indexOnInexact: true }, + ctx, + ); + + expect(checkout).toHaveBeenCalledWith(expect.objectContaining({ + indexOnInexact: true, + })); + }); + it('rejects a missing flow config before requesting an ADT client', async () => { const getAdtClient = vi.fn(async () => ({}) as AdtClient); const command = createFlowCommand(); diff --git a/packages/adt-flow/tests/service.test.ts b/packages/adt-flow/tests/service.test.ts index fb2675e0b..4db5ff6bf 100644 --- a/packages/adt-flow/tests/service.test.ts +++ b/packages/adt-flow/tests/service.test.ts @@ -769,6 +769,51 @@ describe('transport checkout', () => { ).resolves.toContain('SOURCE_HISTORY_SCOPE_VERSION_MISSING'); }); + it('indexes only a manifest-inexact checkout when the caller explicitly opts in', async () => { + const workspace = await root(); + const current = manifest('modified', version('before'), version('after')); + current.entries.push({ + object: { + pgmid: 'R3TR', + type: 'CLAS', + name: 'ZCL_ZZZ_INEXACT', + packageName: 'ZROOT_FEATURE', + }, + component: { id: 'main' }, + sourceTransport: 'DEVK900001', + changeKind: 'ambiguous', + exact: false, + diagnostic: { + code: 'SOURCE_HISTORY_SCOPE_VERSION_MISSING', + message: 'No exact version belongs to the scope.', + }, + }); + const ports = dependencies(() => current); + + await expect( + createAdtFlowService(ports).checkout({ + root: workspace, + transports: ['DEVK900001'], + config, + indexOnInexact: true, + }), + ).resolves.toMatchObject({ + changed: [], + descriptors: expect.arrayContaining(['.adt/tr/DEVK900001.json']), + skipped: [ + { + object: 'CLAS/ZCL_ZZZ_INEXACT', + component: 'main', + diagnostic: 'SOURCE_HISTORY_SCOPE_VERSION_MISSING', + sourceTransport: 'DEVK900001', + }, + ], + sapCalls: { source: 0 }, + }); + expect(ports.readSource).not.toHaveBeenCalled(); + expect(ports.loadObject).not.toHaveBeenCalled(); + }); + it('indexes inventory without requiring a materializing format plugin', async () => { const workspace = await root(); const ports = dependencies(() =>