Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/prompts/feature-flag-rollout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<env_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=<env_id> to execute each phase after user approval.`,
},
}],
};
Expand Down
13 changes: 13 additions & 0 deletions src/registry/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,19 @@ export function flattenTrafficType(item: Record<string, unknown>): 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<string, unknown>;
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") {
Expand Down
44 changes: 23 additions & 21 deletions src/registry/toolsets/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -800,32 +800,33 @@ 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,
identifierFields: ["workspace_id"],
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).",
},
},
},
Expand Down Expand Up @@ -1051,32 +1052,33 @@ 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,
identifierFields: ["workspace_id"],
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).",
},
},
},
Expand Down
158 changes: 139 additions & 19 deletions tests/registry/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string, unknown>][])(
"%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: {} }],
Expand Down
Loading