diff --git a/README.md b/README.md index e6cb9254..628fe08a 100644 --- a/README.md +++ b/README.md @@ -1538,10 +1538,11 @@ Typical workflow: - **`fme_environment`** — `list` wired to the real endpoint. - **`fme_feature_flag`** — dual-mode, both branches fully wired. Harness-native (`org_id`+`project_id`): `list`/`get`/`create`/`delete` hit `/fme/api/v4/feature-flags` (body for `create`: `name`, `trafficType`, optional `description`/`tags`/`owners`, per `CreateFeatureFlagRequest`); `update` sends a merge-patch to `/fme/api/v4/feature-flags/{name}`; `archive`/`unarchive` hit `/fme/api/v4/feature-flags/{name}/archive|unarchive` (optional `comment` only — no `title`, per `ArchiveUnarchiveRequest`); `kill`/`restore`/`reallocate` hit `/fme/api/v4/feature-flag-definitions/{name}/kill|restore|reallocate` with `environment_id` as a query param (optional `comment`/`title`, per `FeatureFlagDefinitionActionRequest`). - **`fme_feature_flag_definition`** — `get`/`create`/`update` remain dual-mode (`workspace_id` or `org_id`+`project_id`). `list`/`delete`/`kill`/`restore`/`reallocate` are Harness-native only (`org_id`+`project_id`) — MCP never had a `workspace_id` contract for those ops. Native list requires `feature_flag_name` and uses `offset`/`limit` (default 100, max 100); it does not take `environment_id`. Delete and execute require `environment_id`. Kill/restore/reallocate are the same actions as on `fme_feature_flag`. Get/create/update body matches legacy (`treatments`, `defaultTreatment`, `defaultRule`, optional `rules`/`baselineTreatment`/`trafficAllocation`/`comment`), plus optional `title` in Harness-native mode. Native update is JSON Merge Patch. -- **`fme_rollout_status`** — `list` is not yet implemented. +- **`fme_rollout_status`** — dual-mode `list`. Pass `org_id`+`project_id` (preferred) or the deprecated `workspace_id`. Native pagination uses `offset`/`limit` (max 100; `harness_list` `size` maps to `limit`); results are promoted to `items`/`total`. Each item has `id`, `name`, and optional `description`. + - **`fme_rule_based_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`/`create`/`delete`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract. - **`fme_rule_based_segment_definition`** — (Deprecated — see `fme_segment_definition`.) Harness-native mode is rejected on every operation/action (`list`/`update`/`enable`/`disable`/`change_request`) — use `fme_segment_definition` instead (no `enable`/`disable`/`change_request` equivalent there); this resource supports only the legacy `workspace_id`/`environment_id` contract. -- **`fme_traffic_type`** — `list` is not yet implemented. +- **`fme_traffic_type`** — dual-mode `list`. Pass `org_id`+`project_id` (preferred) or the deprecated `workspace_id`. Native pagination uses `offset`/`limit` (max 100; `harness_list` `size` maps to `limit`); results are promoted to `items`/`total`. Each item has `id` and `name` (no `displayAttributeId`). - **`fme_identity`** — `create`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call. - **`fme_standard_segment`** — (Deprecated — see `fme_segment`.) Harness-native mode is rejected on every operation (`list`/`get`) — use `fme_segment` instead; this resource supports only the legacy `workspace_id` contract. There is no `create` operation for this resource in either mode. - **`fme_segment_keys`** — `list`/`update` are not yet implemented if `org_id`+`project_id` are passed together; otherwise proceeds as a normal legacy call. diff --git a/src/prompts/feature-flag-rollout.ts b/src/prompts/feature-flag-rollout.ts index 60b72f9f..09f33337 100644 --- a/src/prompts/feature-flag-rollout.ts +++ b/src/prompts/feature-flag-rollout.ts @@ -46,7 +46,7 @@ Steps: 6. **Safety gates**: Identify metrics or health checks between each phase 7. **Rollback plan**: Use kill action to immediately turn off the flag if issues arise -Present the rollout plan for review. Use harness_execute with resource_type="fme_feature_flag", action="kill" or action="restore", ${scopeArgs}, feature_flag_name="${featureFlagName}", environment_id= to execute each phase after user approval.${nativeModeCaveat}`, +Present the rollout plan for review. Use harness_execute with resource_type="fme_feature_flag", action="kill" or action="restore", ${scopeArgs}, feature_flag_name="${featureFlagName}", environment_id= to execute each phase after user approval.`, }, }], }; diff --git a/src/registry/extractors.ts b/src/registry/extractors.ts index 6d4b85e5..dd44262b 100644 --- a/src/registry/extractors.ts +++ b/src/registry/extractors.ts @@ -1128,6 +1128,19 @@ export function flattenTrafficType(item: Record): void { } } +/** + * Public v4 paginated lists (`TrafficTypeListResponse` / `RolloutStatusListResponse`): + * `{ data, limit, offset, totalCount }`. Promote `data`→`items` and `totalCount`→`total` + * so harness_list compact/output schema see a full total, not the current page length. + */ +export const fmeV4PaginatedListExtract = (raw: unknown): unknown => { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw; + const r = raw as Record; + if (!Array.isArray(r.data)) return raw; + const total = typeof r.totalCount === "number" ? r.totalCount : r.data.length; + return { ...r, items: r.data, total }; +}; + /** Extract FME feature flag list — passthrough with trafficType.id flattened on each item. */ export const fmeListExtract = (raw: unknown): unknown => { if (raw && typeof raw === "object") { diff --git a/src/registry/toolsets/feature-flags.ts b/src/registry/toolsets/feature-flags.ts index 5a15d36c..4c53cf6b 100644 --- a/src/registry/toolsets/feature-flags.ts +++ b/src/registry/toolsets/feature-flags.ts @@ -1,5 +1,5 @@ import type { ToolsetDefinition, BodySchema } from "../types.js"; -import { passthrough, fmeListExtract, fmeGetExtract } from "../extractors.js"; +import { passthrough, fmeListExtract, fmeGetExtract, fmeV4PaginatedListExtract } from "../extractors.js"; import { isFmeHarnessNativeSelected, logFmeDeprecation, requireFmeIdentifier, requireHarnessNativeSegmentScope, resolveFmeDualMode } from "../scope-utils.js"; const fmeActionExtract = (raw: unknown) => { @@ -800,7 +800,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_rollout_status", displayName: "FME Rollout Status", description: - "Rollout status definitions for a workspace (e.g. Killed, Permanent, Ramping). Use to discover valid rollout_status_id UUIDs for filtering fme_feature_flag lists. Note: this endpoint may not be available on all account types — rollout status IDs are also returned inline with fme_feature_flag list results.", + "Rollout status definitions (e.g. Killed, Permanent, Ramping). Dual-mode: pass org_id+project_id (preferred) or the deprecated workspace_id. Use harness_list to discover rollout_status_id UUIDs for filtering fme_feature_flag lists. Pagination uses offset/limit (max 100; harness_list size maps to limit).", toolset: "feature-flags", scope: "account", scopeOptional: true, @@ -808,24 +808,25 @@ export const featureFlagsToolset: ToolsetDefinition = { product: "fme", listFilterFields: [ { name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." }, + { name: "offset", description: "Harness-native pagination offset (default 0)", type: "number" }, + { name: "limit", description: "Harness-native page size (default 100, max 100)", type: "number" }, ], operations: { list: { method: "GET", - path: "/internal/api/v2/rolloutStatuses/ws/{wsId}", + path: "", routeResolver: (input) => { const mode = resolveFmeDualMode(input, "fme_rollout_status"); - if (mode.mode === "harness_native") { - throw new Error( - "fme_rollout_status.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", - ); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/rolloutStatuses/ws/${encodeURIComponent(mode.workspaceId)}` }; } - return { path: `/internal/api/v2/rolloutStatuses/ws/${encodeURIComponent(mode.workspaceId)}` }; + return { path: "/fme/api/v4/rollout-statuses", product: "harness", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS }; }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId" }, - responseExtractor: passthrough, - description: "List rollout status definitions for a workspace (Killed, Permanent, Ramping, etc.). If this returns 404, use rolloutStatus fields from fme_feature_flag list results instead.", + queryParams: { offset: "offset", size: "limit", limit: "limit" }, + responseExtractor: fmeV4PaginatedListExtract, + description: + "List rollout statuses. Pass org_id+project_id (preferred) or deprecated workspace_id. Optional offset/limit (harness_list size maps to limit).", }, }, }, @@ -1051,7 +1052,7 @@ export const featureFlagsToolset: ToolsetDefinition = { resourceType: "fme_traffic_type", displayName: "FME Traffic Type", description: - "Traffic type in a workspace (e.g. 'user', 'account'). List traffic types to discover traffic_type_id values needed for identity queries and flag/segment creation.", + "Traffic type (e.g. 'user', 'account'). Dual-mode: pass org_id+project_id (preferred) or the deprecated workspace_id. Use harness_list to discover traffic_type_id / name values for flag and segment create. Pagination uses offset/limit (max 100; harness_list size maps to limit).", toolset: "feature-flags", scope: "account", scopeOptional: true, @@ -1059,24 +1060,25 @@ export const featureFlagsToolset: ToolsetDefinition = { product: "fme", listFilterFields: [ { name: "workspace_id", description: "FME workspace ID (get from fme_workspace). Deprecated — omit and pass org_id+project_id instead for Harness-native scoping." }, + { name: "offset", description: "Harness-native pagination offset (default 0)", type: "number" }, + { name: "limit", description: "Harness-native page size (default 100, max 100)", type: "number" }, ], operations: { list: { method: "GET", - path: "/internal/api/v2/trafficTypes/ws/{wsId}", + path: "", routeResolver: (input) => { const mode = resolveFmeDualMode(input, "fme_traffic_type"); - if (mode.mode === "harness_native") { - throw new Error( - "fme_traffic_type.list: Harness-native (org_id/project_id) mode not yet implemented for this operation — pass workspace_id (deprecated) instead.", - ); + if (mode.mode === "legacy") { + return { path: `/internal/api/v2/trafficTypes/ws/${encodeURIComponent(mode.workspaceId)}` }; } - return { path: `/internal/api/v2/trafficTypes/ws/${encodeURIComponent(mode.workspaceId)}` }; + return { path: "/fme/api/v4/traffic-types", product: "harness", scopeParams: FME_HARNESS_NATIVE_SCOPE_PARAMS }; }, operationPolicy: { risk: "read", retryPolicy: "safe" }, - pathParams: { workspace_id: "wsId" }, - responseExtractor: passthrough, - description: "List traffic types for a workspace. Returns id, name, and displayAttributeId for each traffic type.", + queryParams: { offset: "offset", size: "limit", limit: "limit" }, + responseExtractor: fmeV4PaginatedListExtract, + description: + "List traffic types. Pass org_id+project_id (preferred) or deprecated workspace_id. Optional offset/limit (harness_list size maps to limit).", }, }, }, diff --git a/tests/registry/feature-flags.test.ts b/tests/registry/feature-flags.test.ts index f60fa3f8..4217e926 100644 --- a/tests/registry/feature-flags.test.ts +++ b/tests/registry/feature-flags.test.ts @@ -842,6 +842,145 @@ describe("fme_environment dual-mode routing", () => { }); }); +describe("fme_traffic_type and fme_rollout_status dual-mode list", () => { + let registry: Registry; + + beforeEach(() => { + registry = new Registry(makeConfig()); + }); + + it.each(["fme_traffic_type", "fme_rollout_status"] as const)( + "%s descriptions stay tool-facing (no HTTP paths or list-only restatement)", + (resourceType) => { + const resource = findResource(resourceType); + const listDescription = resource.operations.list?.description ?? ""; + + expect(resource.description).not.toMatch(/\/fme\/api\//); + expect(resource.description).not.toMatch(/\/internal\/api\//); + expect(resource.description).not.toMatch(/List-only/i); + expect(resource.description).not.toMatch(/Native items/i); + expect(resource.description).not.toMatch(/Native results/i); + expect(resource.description).not.toMatch(/displayAttributeId/); + expect(listDescription).not.toMatch(/\/fme\/api\//); + expect(listDescription).not.toMatch(/\/internal\/api\//); + expect(listDescription).not.toMatch(/Native results/i); + expect(resource.description).toMatch(/org_id\+project_id/); + expect(resource.description).toMatch(/workspace_id/); + }, + ); + + it.each([ + ["fme_traffic_type", "/fme/api/v4/traffic-types", "/internal/api/v2/trafficTypes/ws/ws1"], + ["fme_rollout_status", "/fme/api/v4/rollout-statuses", "/internal/api/v2/rolloutStatuses/ws/ws1"], + ] as const)("%s: native list uses v4 path and FME scope params", async (resourceType, nativePath, _legacyPath) => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, resourceType, "list", { org_id: "o1", project_id: "p1" }); + + const req = firstRequest(mockRequest); + expect(req.path).toBe(nativePath); + expect(req.product).toBeUndefined(); + expect(req.params).toMatchObject({ + account_id: "test-account", + organization_identifier: "o1", + project_identifier: "p1", + }); + expect(req.params?.orgIdentifier).toBeUndefined(); + expect(req.params?.projectIdentifier).toBeUndefined(); + }); + + it.each([ + ["fme_traffic_type", "/internal/api/v2/trafficTypes/ws/ws1"], + ["fme_rollout_status", "/internal/api/v2/rolloutStatuses/ws/ws1"], + ] as const)("%s: legacy list keeps Split Admin path", async (resourceType, legacyPath) => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, resourceType, "list", { workspace_id: "ws1" }); + + const req = firstRequest(mockRequest); + expect(req.path).toBe(legacyPath); + expect(req.product).toBe("fme"); + }); + + it("native list forwards offset and limit as query params", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_traffic_type", "list", { + org_id: "o1", + project_id: "p1", + offset: 10, + limit: 25, + }); + + expect(firstRequest(mockRequest).params).toMatchObject({ offset: 10, limit: 25 }); + }); + + it("native list maps harness_list size onto Java limit", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_rollout_status", "list", { + org_id: "o1", + project_id: "p1", + size: 20, + }); + + expect(firstRequest(mockRequest).params).toMatchObject({ limit: 20 }); + }); + + it("native list prefers explicit limit over size", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await registry.dispatch(client, "fme_traffic_type", "list", { + org_id: "o1", + project_id: "p1", + size: 20, + limit: 5, + }); + + expect(firstRequest(mockRequest).params?.limit).toBe(5); + }); + + it("native list promotes totalCount to total and data to items", async () => { + const mockRequest = vi.fn().mockResolvedValue({ + data: [{ type: "TRAFFIC_TYPE", id: "tt1", name: "user" }], + limit: 100, + offset: 0, + totalCount: 3, + }); + const client = makeClient(mockRequest); + + const result = await registry.dispatch(client, "fme_traffic_type", "list", { + org_id: "o1", + project_id: "p1", + }); + + expect(result).toMatchObject({ + items: [{ type: "TRAFFIC_TYPE", id: "tt1", name: "user" }], + total: 3, + totalCount: 3, + }); + }); + + it("rejects mixed workspace_id and org/project", async () => { + const mockRequest = vi.fn().mockResolvedValue({}); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, "fme_rollout_status", "list", { + workspace_id: "ws1", + org_id: "o1", + project_id: "p1", + }), + ).rejects.toThrow("fme_rollout_status: pass either workspace_id (deprecated) OR org_id+project_id, not both."); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + describe("fme_standard_segment — legacy only, Harness-native rejected in favor of fme_segment", () => { let registry: Registry; @@ -1123,25 +1262,6 @@ describe("FME new-mode (NYI) resources", () => { registry = new Registry(makeConfig()); }); - it.each([ - ["fme_rollout_status", "list", { workspace_id: "ws1" }], - ["fme_traffic_type", "list", { workspace_id: "ws1" }], - ] as [string, "get" | "create" | "update" | "list", Record][])( - "%s.%s: legacy mode still works, new mode throws not-yet-implemented", - async (resourceType, operation, legacyInput) => { - const mockRequest = vi.fn().mockResolvedValue({}); - const client = makeClient(mockRequest); - - await registry.dispatch(client, resourceType, operation, legacyInput); - expect(mockRequest).toHaveBeenCalledTimes(1); - - const newModeInput = { ...legacyInput, workspace_id: undefined, org_id: "o1", project_id: "p1" }; - await expect(registry.dispatch(client, resourceType, operation, newModeInput)).rejects.toThrow( - /not yet implemented/i, - ); - }, - ); - it.each([ ["list", { workspace_id: "ws1", environment_id: "e1" }], ["update", { workspace_id: "ws1", segment_name: "seg1", environment_id: "e1", body: {} }],