* fix(policies): specificity-aware default position on the server The UI computed a specificity-aware position for new rules client-side, but the server defaulted a positionless create to the TOP of the owner's list. When the client's policy list was stale (e.g. the optimistic atom hadn't refreshed between two quick writes), the UI sent no usable position and the top-of-list default let a broad category rule shadow a narrower leaf rule written moments earlier — surfaced by the policies-ui scenario failing against the production selfhost image. Move the specificity placement into the sdk (positionForNewPattern) and make it the server-side default for create; the React hook now reuses the same helper so the optimistic order matches what the server commits. * fix(policies): badge Clear works while the policies list is mid-refresh The detail badge's Clear resolved the rule to remove by pattern-matching the local policies list. Right after the badge authors a rule, the post-commit refetch may not have landed, so the lookup missed and the clear silently no-oped — the second race the policies-ui scenario hit on the production image. Thread the resolved EffectivePolicy's policyId through as a fallback for exactly that window.
Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Policy create now defaults a new rule's position below any more-specific existing rule on the server, so a broad rule written without an explicit position (stale UI, API, agent tool) cannot shadow an existing narrower rule. | ||
| @@ -5,8 +5,6 @@ import { memoryAdapter } from "@executor-js/fumadb/adapters/memory"; · 4 unmodified lines | |||
| 5 | import { withQueryContext, type Condition, type ConditionBuilder } from "@executor-js/fumadb/query"; | 5 | import { withQueryContext, type Condition, type ConditionBuilder } from "@executor-js/fumadb/query"; |
| 6 | import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema"; | 6 | import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema"; |
| 7 | import type { AnyColumn } from "@executor-js/fumadb/schema"; | 7 | import type { AnyColumn } from "@executor-js/fumadb/schema"; |
| 8 | import { generateKeyBetween } from "fractional-indexing"; | ||
| 9 | |||
| 10 | import { | 8 | import { |
| 11 | StorageError, | 9 | StorageError, |
| 12 | isStorageFailure, | 10 | isStorageFailure, |
| @@ -99,6 +97,7 @@ import { · 86 unmodified lines | |||
| 99 | comparePolicyRow, | 97 | comparePolicyRow, |
| 100 | isValidPattern, | 98 | isValidPattern, |
| 101 | matchPattern, | 99 | matchPattern, |
| 100 | positionForNewPattern, | ||
| 102 | resolveEffectivePolicy, | 101 | resolveEffectivePolicy, |
| 103 | rowToToolPolicy, | 102 | rowToToolPolicy, |
| 104 | type CreateToolPolicyInput, | 103 | type CreateToolPolicyInput, |
| @@ -3407,11 +3406,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3302 unmodified lines | |||
| 3407 | const existing = yield* core.findMany("tool_policy", { | 3406 | const existing = yield* core.findMany("tool_policy", { |
| 3408 | where: byOwner(input.owner), | 3407 | where: byOwner(input.owner), |
| 3409 | }); | 3408 | }); |
| 3410 | const minPosition = existing | 3409 | // Default placement is specificity-aware (below any more-specific |
| 3411 | .map((row) => row.position) | 3410 | // rule), not top-of-list: a client that omits position — the UI when |
| 3412 | .sort() | 3411 | // its policy list is stale, the API, an agent tool — must not have its |
| 3413 | .at(0); | 3412 | // broad rule silently shadow an existing narrow one. |
| 3414 | const position = input.position ?? generateKeyBetween(null, minPosition ?? null); | 3413 | const position = input.position ?? positionForNewPattern(input.pattern, existing); |
| 3415 | const id = PolicyId.make( | 3414 | const id = PolicyId.make( |
| 3416 | `pol_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, | 3415 | `pol_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, |
| 3417 | ); | 3416 | ); |
| @@ -367,7 +367,7 @@ describe("executor.policies", () => { · 366 unmodified lines | |||
| 367 | }), | 367 | }), |
| 368 | ); | 368 | ); |
| 369 | 369 | ||
| 370 | it.effect("create defaults new rules to the top of the list", () => | 370 | it.effect("create defaults new rules above equally-or-less-specific ones", () => |
| 371 | Effect.gen(function* () { | 371 | Effect.gen(function* () { |
| 372 | const executor = yield* setupExecutor(); | 372 | const executor = yield* setupExecutor(); |
| 373 | const first = yield* executor.policies.create({ | 373 | const first = yield* executor.policies.create({ |
| @@ -387,6 +387,47 @@ describe("executor.policies", () => { · 13 unmodified lines | |||
| 387 | }), | 387 | }), |
| 388 | ); | 388 | ); |
| 389 | 389 | ||
| 390 | it.effect("create places a broad rule below an existing narrower one", () => | ||
| 391 | Effect.gen(function* () { | ||
| 392 | const executor = yield* setupExecutor(); | ||
| 393 | // Narrow leaf rule first, then a broad category rule WITHOUT an explicit | ||
| 394 | // position — the category rule must not shadow the leaf rule. | ||
| 395 | yield* executor.policies.create({ | ||
| 396 | owner: "org", | ||
| 397 | pattern: "vercel.*.*.records.create", | ||
| 398 | action: "block", | ||
| 399 | }); | ||
| 400 | yield* executor.policies.create({ | ||
| 401 | owner: "org", | ||
| 402 | pattern: "vercel.*.*.records.*", | ||
| 403 | action: "require_approval", | ||
| 404 | }); | ||
| 405 | |||
| 406 | const rules = yield* executor.policies.list(); | ||
| 407 | expect(rules.map((r) => r.pattern)).toEqual([ | ||
| 408 | "vercel.*.*.records.create", | ||
| 409 | "vercel.*.*.records.*", | ||
| 410 | ]); | ||
| 411 | |||
| 412 | const rows = rules.map( | ||
| 413 | (r) => | ||
| 414 | ({ | ||
| 415 | id: String(r.id), | ||
| 416 | owner: r.owner, | ||
| 417 | subject: "", | ||
| 418 | pattern: r.pattern, | ||
| 419 | action: r.action, | ||
| 420 | position: r.position, | ||
| 421 | created_at: r.createdAt, | ||
| 422 | updated_at: r.updatedAt, | ||
| 423 | }) as ToolPolicyRow, | ||
| 424 | ); | ||
| 425 | const match = resolveToolPolicy("vercel.org.acct.records.create", rows, () => 0); | ||
| 426 | expect(match?.action).toBe("block"); | ||
| 427 | expect(match?.pattern).toBe("vercel.*.*.records.create"); | ||
| 428 | }), | ||
| 429 | ); | ||
| 430 | |||
| 390 | it.effect("create stores rules at the requested owner", () => | 431 | it.effect("create stores rules at the requested owner", () => |
| 391 | Effect.gen(function* () { | 432 | Effect.gen(function* () { |
| 392 | const executor = yield* setupExecutor(); | 433 | const executor = yield* setupExecutor(); |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | |||
| 10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 11 | 11 | ||
| 12 | import { Match, Schema } from "effect"; | 12 | import { Match, Schema } from "effect"; |
| 13 | import { generateKeyBetween } from "fractional-indexing"; | ||
| 13 | 14 | ||
| 14 | import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema"; | 15 | import type { ToolPolicyAction, ToolPolicyRow } from "./core-schema"; |
| 15 | import { Owner, PolicyId } from "./ids"; | 16 | import { Owner, PolicyId } from "./ids"; |
| @@ -139,6 +140,46 @@ export const comparePolicyRow = ( · 123 unmodified lines | |||
| 139 | return ia < ib ? -1 : ia > ib ? 1 : 0; | 140 | return ia < ib ? -1 : ia > ib ? 1 : 0; |
| 140 | }; | 141 | }; |
| 141 | 142 | ||
| 143 | // Specificity score for ordering. Higher = more specific = should sit at a | ||
| 144 | // lower position-key (higher precedence). New rules are auto-placed below | ||
| 145 | // any more-specific existing rules so a freshly-added group rule never | ||
| 146 | // silently shadows an existing leaf rule. | ||
| 147 | // `*` → 0 | ||
| 148 | // `vercel.*` → 2 (1 literal segment, wildcard) | ||
| 149 | // `vercel.dns.*` → 4 (2 literal segments, wildcard) | ||
| 150 | // `vercel.dns` → 5 (2 literal segments, exact — beats same-prefix wildcard) | ||
| 151 | // `vercel.dns.create` → 7 (3 literal segments, exact) | ||
| 152 | export const patternSpecificity = (pattern: string): number => { | ||
| 153 | if (pattern === "*") return 0; | ||
| 154 | if (pattern.endsWith(".*")) { | ||
| 155 | const prefix = pattern.slice(0, -2); | ||
| 156 | return prefix.split(".").length * 2; | ||
| 157 | } | ||
| 158 | return pattern.split(".").length * 2 + 1; | ||
| 159 | }; | ||
| 160 | |||
| 161 | /** | ||
| 162 | * Position key for a new rule among an owner's existing rules, placed just | ||
| 163 | * below every existing rule that is MORE specific (and above everything | ||
| 164 | * equally or less specific). Rows must be the owner's committed rules; order | ||
| 165 | * doesn't matter, they're sorted here. This is the authoritative default — | ||
| 166 | * the server applies it when `create` gets no explicit position, so a rule | ||
| 167 | * written by any client (UI, API, agent tool) cannot shadow a more-specific | ||
| 168 | * existing rule by racing to the top of the list. | ||
| 169 | */ | ||
| 170 | export const positionForNewPattern = ( | ||
| 171 | pattern: string, | ||
| 172 | rows: ReadonlyArray<Pick<ToolPolicyRow, "pattern" | "position" | "id">>, | ||
| 173 | ): string => { | ||
| 174 | const committed = [...rows].sort(comparePolicyRow); | ||
| 175 | const newScore = patternSpecificity(pattern); | ||
| 176 | let idx = committed.findIndex((r) => patternSpecificity(r.pattern) <= newScore); | ||
| 177 | if (idx === -1) idx = committed.length; // below every more-specific rule | ||
| 178 | const prev = idx === 0 ? null : committed[idx - 1]!.position; | ||
| 179 | const next = idx === committed.length ? null : committed[idx]!.position; | ||
| 180 | return generateKeyBetween(prev, next); | ||
| 181 | }; | ||
| 182 | |||
| 142 | const actionRestrictionRank = (action: ToolPolicyAction): number => | 183 | const actionRestrictionRank = (action: ToolPolicyAction): number => |
| 143 | Match.value(action).pipe( | 184 | Match.value(action).pipe( |
| 144 | Match.when("block", () => 3), | 185 | Match.when("block", () => 3), |
| @@ -88,6 +88,8 @@ export { · 87 unmodified lines | |||
| 88 | isValidPattern, | 88 | isValidPattern, |
| 89 | effectivePolicyFromSorted, | 89 | effectivePolicyFromSorted, |
| 90 | comparePolicyRow, | 90 | comparePolicyRow, |
| 91 | patternSpecificity, | ||
| 92 | positionForNewPattern, | ||
| 91 | ToolPolicyActionSchema, | 93 | ToolPolicyActionSchema, |
| 92 | type ToolPolicy, | 94 | type ToolPolicy, |
| 93 | type CreateToolPolicyInput, | 95 | type CreateToolPolicyInput, |
| @@ -171,7 +171,7 @@ export function ToolDetail(props: { · 170 unmodified lines | |||
| 171 | /** When provided, the policy badge becomes a dropdown trigger that | 171 | /** When provided, the policy badge becomes a dropdown trigger that |
| 172 | * applies a user rule to this tool's exact id. */ | 172 | * applies a user rule to this tool's exact id. */ |
| 173 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 173 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 174 | onClearPolicy?: (pattern: string) => void; | 174 | onClearPolicy?: (pattern: string, policyId?: string) => void; |
| 175 | patternForDisplay?: (displayPattern: string) => string; | 175 | patternForDisplay?: (displayPattern: string) => string; |
| 176 | /** Run-tab wiring. When `integration` + `runToolName` are provided, a third | 176 | /** Run-tab wiring. When `integration` + `runToolName` are provided, a third |
| 177 | * "Run" tab hosts the per-connection tool tester. */ | 177 | * "Run" tab hosts the per-connection tool tester. */ |
| @@ -413,7 +413,7 @@ function PolicyBadgeMenu(props: { · 235 unmodified lines | |||
| 413 | staticTool?: boolean; | 413 | staticTool?: boolean; |
| 414 | policy?: EffectivePolicy; | 414 | policy?: EffectivePolicy; |
| 415 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; | 415 | onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; |
| 416 | onClearPolicy?: (pattern: string) => void; | 416 | onClearPolicy?: (pattern: string, policyId?: string) => void; |
| 417 | patternForDisplay?: (displayPattern: string) => string; | 417 | patternForDisplay?: (displayPattern: string) => string; |
| 418 | }) { | 418 | }) { |
| 419 | const interactive = !!props.onSetPolicy; | 419 | const interactive = !!props.onSetPolicy; |
| @@ -487,7 +487,10 @@ function PolicyBadgeMenu(props: { · 67 unmodified lines | |||
| 487 | <> | 487 | <> |
| 488 | <DropdownMenuSeparator /> | 488 | <DropdownMenuSeparator /> |
| 489 | <DropdownMenuItem | 489 | <DropdownMenuItem |
| 490 | onSelect={() => props.onClearPolicy?.(pattern)} | 490 | // Pass the resolved rule's id: right after this menu authored |
| 491 | // the rule, the policies list may still be mid-refresh, and a | ||
| 492 | // pattern-only lookup there would silently no-op the clear. | ||
| 493 | onSelect={() => props.onClearPolicy?.(pattern, props.policy?.policyId)} | ||
| 491 | className="text-muted-foreground" | 494 | className="text-muted-foreground" |
| 492 | > | 495 | > |
| 493 | Clear | 496 | Clear |
| @@ -1,8 +1,12 @@ | |||
| 1 | import { useCallback, useMemo } from "react"; | 1 | import { useCallback, useMemo, useRef } from "react"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import { generateKeyBetween } from "fractional-indexing"; | 4 | import { |
| 5 | import { PolicyId, type Owner, type ToolPolicyAction } from "@executor-js/sdk/shared"; | 5 | PolicyId, |
| 6 | positionForNewPattern, | ||
| 7 | type Owner, | ||
| 8 | type ToolPolicyAction, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 6 | 10 | ||
| 7 | import { | 11 | import { |
| 8 | createPolicyOptimistic, | 12 | createPolicyOptimistic, |
| @@ -13,31 +17,17 @@ import { · 4 unmodified lines | |||
| 13 | import { policyWriteKeys } from "../api/reactivity-keys"; | 17 | import { policyWriteKeys } from "../api/reactivity-keys"; |
| 14 | import { trackEvent } from "../api/analytics"; | 18 | import { trackEvent } from "../api/analytics"; |
| 15 | 19 | ||
| 16 | // Specificity score for ordering. Higher = more specific = should sit at a | ||
| 17 | // lower position-key (higher precedence). New rules are auto-placed below | ||
| 18 | // any more-specific existing rules so a freshly-added group rule never | ||
| 19 | // silently shadows an existing leaf rule. | ||
| 20 | // `*` → 0 | ||
| 21 | // `vercel.*` → 2 (1 literal segment, wildcard) | ||
| 22 | // `vercel.dns.*` → 4 (2 literal segments, wildcard) | ||
| 23 | // `vercel.dns` → 5 (2 literal segments, exact — beats same-prefix wildcard) | ||
| 24 | // `vercel.dns.create` → 7 (3 literal segments, exact) | ||
| 25 | const specificity = (pattern: string): number => { | ||
| 26 | if (pattern === "*") return 0; | ||
| 27 | if (pattern.endsWith(".*")) { | ||
| 28 | const prefix = pattern.slice(0, -2); | ||
| 29 | return prefix.split(".").length * 2; | ||
| 30 | } | ||
| 31 | return pattern.split(".").length * 2 + 1; | ||
| 32 | }; | ||
| 33 | |||
| 34 | export interface PolicyAction { | 20 | export interface PolicyAction { |
| 35 | /** Set the action on a pattern. If a user rule with this exact pattern | 21 | /** Set the action on a pattern. If a user rule with this exact pattern |
| 36 | * already exists, update it. Otherwise create with auto-placed | 22 | * already exists, update it. Otherwise create with auto-placed |
| 37 | * position so more-specific rules keep precedence. */ | 23 | * position so more-specific rules keep precedence. */ |
| 38 | readonly set: (pattern: string, action: ToolPolicyAction) => Promise<void>; | 24 | readonly set: (pattern: string, action: ToolPolicyAction) => Promise<void>; |
| 39 | /** Remove the user rule with this exact pattern, if any. No-op if none. */ | 25 | /** Remove the user rule with this exact pattern, if any. `policyId` is the |
| 40 | readonly clear: (pattern: string) => Promise<void>; | 26 | * id of the rule the caller believes is active (e.g. from the tool's |
| 27 | * resolved EffectivePolicy); it is the fallback when the local policies | ||
| 28 | * list is mid-refresh and doesn't contain the rule yet — without it a | ||
| 29 | * clear in that window silently no-ops. */ | ||
| 30 | readonly clear: (pattern: string, policyId?: string) => Promise<void>; | ||
| 41 | /** True while a write is in flight. */ | 31 | /** True while a write is in flight. */ |
| 42 | readonly busy: boolean; | 32 | readonly busy: boolean; |
| 43 | } | 33 | } |
| @@ -83,20 +73,23 @@ export const usePolicyActions = (owner: Owner = "org"): PolicyAction => { · 39 unmodified lines | |||
| 83 | 73 | ||
| 84 | const busy = policies.waiting; | 74 | const busy = policies.waiting; |
| 85 | 75 | ||
| 76 | // Server-assigned ids of rules this hook created, by pattern. The local | ||
| 77 | // policies list is optimistic: right after a create it holds a placeholder | ||
| 78 | // row with a fake `pending-*` id (and the resolved EffectivePolicy a caller | ||
| 79 | // hands back can carry that same fake id), so neither is safe to DELETE | ||
| 80 | // with. The create response is the one authoritative id source in that | ||
| 81 | // window. | ||
| 82 | const createdIdByPattern = useRef(new Map<string, string>()); | ||
| 83 | |||
| 84 | // Specificity-aware placement (below any more-specific rule) via the shared | ||
| 85 | // sdk helper — the same computation the server applies when no position is | ||
| 86 | // sent. Computing it here too keeps the optimistic UI's final order stable; | ||
| 87 | // if this client's list is stale, the server default is the backstop. | ||
| 86 | const computePosition = useCallback( | 88 | const computePosition = useCallback( |
| 87 | (newPattern: string): string | undefined => { | 89 | (newPattern: string): string | undefined => { |
| 88 | const committed = sorted.filter((r) => r.position !== ""); | 90 | const committed = sorted.filter((r) => r.position !== ""); |
| 89 | if (committed.length === 0) return undefined; | 91 | if (committed.length === 0) return undefined; |
| 90 | const newScore = specificity(newPattern); | 92 | return positionForNewPattern(newPattern, committed); |
| 91 | // Walk down the list (most-precedent first); place the new rule | ||
| 92 | // just before the first existing rule whose specificity is <= the | ||
| 93 | // new one. That way more-specific rules stay above us, and we win | ||
| 94 | // against everything equally or less specific. | ||
| 95 | let idx = committed.findIndex((r) => specificity(r.pattern) <= newScore); | ||
| 96 | if (idx === -1) idx = committed.length; // append at bottom | ||
| 97 | const prev = idx === 0 ? null : committed[idx - 1]!.position; | ||
| 98 | const next = idx === committed.length ? null : committed[idx]!.position; | ||
| 99 | return generateKeyBetween(prev, next); | ||
| 100 | }, | 93 | }, |
| 101 | [sorted], | 94 | [sorted], |
| 102 | ); | 95 | ); |
| @@ -121,24 +114,33 @@ export const usePolicyActions = (owner: Owner = "org"): PolicyAction => { · 18 unmodified lines | |||
| 121 | return; | 114 | return; |
| 122 | } | 115 | } |
| 123 | const position = computePosition(pattern); | 116 | const position = computePosition(pattern); |
| 124 | await doCreate({ | 117 | const created = await doCreate({ |
| 125 | payload: | 118 | payload: |
| 126 | position === undefined | 119 | position === undefined |
| 127 | ? { owner, pattern, action } | 120 | ? { owner, pattern, action } |
| 128 | : { owner, pattern, action, position }, | 121 | : { owner, pattern, action, position }, |
| 129 | reactivityKeys: policyWriteKeys, | 122 | reactivityKeys: policyWriteKeys, |
| 130 | }); | 123 | }); |
| 124 | createdIdByPattern.current.set(pattern, String(created.id)); | ||
| 131 | trackEvent("tool_policy_set", { action, pattern_kind: patternKind, owner }); | 125 | trackEvent("tool_policy_set", { action, pattern_kind: patternKind, owner }); |
| 132 | }, | 126 | }, |
| 133 | [owner, doCreate, doUpdate, findExact, computePosition], | 127 | [owner, doCreate, doUpdate, findExact, computePosition], |
| 134 | ); | 128 | ); |
| 135 | 129 | ||
| 136 | const clear = useCallback( | 130 | const clear = useCallback( |
| 137 | async (pattern: string) => { | 131 | async (pattern: string, policyId?: string) => { |
| 132 | // Resolve the id to delete, most-authoritative first. The local list | ||
| 133 | // and the caller's resolved policy can both hold an optimistic | ||
| 134 | // `pending-*` placeholder id right after a create (before the | ||
| 135 | // post-commit refetch lands); the create response we recorded is real, | ||
| 136 | // and a placeholder id must never reach the server. | ||
| 138 | const existing = findExact(pattern); | 137 | const existing = findExact(pattern); |
| 139 | if (!existing) return; | 138 | const isReal = (id: string | undefined): id is string => !!id && !id.startsWith("pending-"); |
| 139 | const id = [existing?.id, createdIdByPattern.current.get(pattern), policyId].find(isReal); | ||
| 140 | if (!id) return; | ||
| 141 | createdIdByPattern.current.delete(pattern); | ||
| 140 | await doRemove({ | 142 | await doRemove({ |
| 141 | params: { policyId: PolicyId.make(existing.id) }, | 143 | params: { policyId: PolicyId.make(id) }, |
| 142 | payload: { owner }, | 144 | payload: { owner }, |
| 143 | reactivityKeys: policyWriteKeys, | 145 | reactivityKeys: policyWriteKeys, |
| 144 | }); | 146 | }); |
| @@ -526,7 +526,9 @@ export function IntegrationDetailPage(props: { · 525 unmodified lines | |||
| 526 | staticTool={selection?.static} | 526 | staticTool={selection?.static} |
| 527 | policy={selectedTool.policy} | 527 | policy={selectedTool.policy} |
| 528 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | 528 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} |
| 529 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} | 529 | onClearPolicy={(pattern, policyId) => |
| 530 | void policyActions.clear(pattern, policyId) | ||
| 531 | } | ||
| 530 | {...(!selection?.static && selectedBareName | 532 | {...(!selection?.static && selectedBareName |
| 531 | ? { | 533 | ? { |
| 532 | integration: slug, | 534 | integration: slug, |
| @@ -157,7 +157,9 @@ export function ToolsPage() { · 156 unmodified lines | |||
| 157 | staticTool={selection?.static} | 157 | staticTool={selection?.static} |
| 158 | policy={selectedTool.policy} | 158 | policy={selectedTool.policy} |
| 159 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} | 159 | onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} |
| 160 | onClearPolicy={(pattern) => void policyActions.clear(pattern)} | 160 | onClearPolicy={(pattern, policyId) => |
| 161 | void policyActions.clear(pattern, policyId) | ||
| 162 | } | ||
| 161 | /> | 163 | /> |
| 162 | ) : ( | 164 | ) : ( |
| 163 | <ToolDetailEmpty hasTools={summaries.length > 0} /> | 165 | <ToolDetailEmpty hasTools={summaries.length > 0} /> |