🐛 fix(cli): resolve rule ids before submitting a standard - #1
Closed
987poiuytrewq wants to merge 4 commits into
Closed
987poiuytrewq wants to merge 4 commits into
987poiuytrewq wants to merge 4 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… job Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
) * ♻️ refactor(commands): give the existing error class a kind CommandSlugAlreadyExistsError lived in @packmind/types as a bare Error, so DomainExceptionFilter had nothing to map it by and only the controller's instanceof check kept it from answering 500. It now extends CommandsError and carries kind conflict, reason command_slug_already_exists and a { commandSlug, spaceId } context. The slug, which the caller typed, stays in the message; the space id moves to the context. It keeps its public slug and spaceId properties, drops the captureStackTrace boilerplate and names itself correctly instead of RecipeSlugAlreadyExistsError. The class moves into packages/commands/src/domain/errors, next to the new CommandsError and CommandsInternalError bases, so the family lives together as it does for standards and skills. The types commands/errors directory is gone, and the barrel is exported from @packmind/commands. The controller keeps its ConflictException mapping for now; only its import changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ♻️ refactor(commands): type the use case failures The bare `throw new Error(...)` across the commands use cases answered 500 with a stack logged at error, when every one was a 404 the caller could act on. They now throw CommandSpaceNotAccessibleError or CommandNotFoundError, both not_found on CommandsError. No use case failure turned out to be ours, so none lands on CommandsInternalError. Each "missing" and "wrong tenant" pair collapses into a single branch with one message: a space that is not there or belongs to another organization, in capture, capture with packages, update from UI, delete, get by id and list by space; a command that is not there or sits in another space, in update from UI and delete. Neither message names an id any more; the ids move to the context. GetCommandById already answered a missing command with `{ recipe: null }`; a command in another space now takes that same path instead of throwing. Its only checked callers, the api service's getCommandById and getLatestVersionNumber, already turn null into a NotFoundException, so that request now answers 404 where it answered 500. The internal lookup the deployments and playbook packages use is untouched. ListCommandsBySpace only ever checked the space, never each command: the repository query is scoped by space. It keeps that one check, typed. The space class carries the Command prefix because @packmind/deployments, @packmind/editions and @packmind/standards already export space-not-found classes. Drops the log-and-rethrow catch blocks — capture, delete, batch delete, get by id, list by space, find by slug, get version and list versions — and the logs sitting right before the converted throws: the filter records every thrown failure once, with its level, stack and typed context. The catch in capture with packages, which swallows a failed package link once the command is created, stays. Specs assert the errors by instance rather than by message literal, and DeleteCommand gains cases for a command in another space and a space in another organization. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ♻️ refactor(commands): type the service, repository, adapter and job failures The last bare errors in the package. CommandService's four "Recipe with id … not found" — in update, delete, duplicate to space and mark as moved — are reached from other domains through the adapter with an id they were handed, so they are the caller's: CommandNotFoundError, not_found. The adapter and job factory wiring failures are ours, all on CommandsInternalError: CommandsAdapterPortsMissingError names the ports or delayed jobs initialize() came back without, and the deploy-commands queue handle's lifecycle guards become DeployCommandsQueueNotInitializedError and DeployCommandsDelayedJobNotCreatedError, as the git fetch-file-content factory does. getDelayedJob() guards the same missing createQueue() call as the queue's initialize(), so both throw the latter. CommandsHexa throws nothing of its own: registry.getService and getAdapter already raise on a missing entry, and the hexa's logging stays — it runs at boot, where the filter never sees what is thrown. The placeholder hexa_dependency_missing reason goes with the dependency context it came with; the internal reasons are now the three above. Drops the log-and-rethrow catch blocks across CommandService, CommandVersionService, CommandRepository and CommandVersionRepository, and the logs sitting right before the converted throws: the filter records every thrown failure once. No repository miss needed a type of its own. Specs assert CommandNotFoundError by instance rather than by message literal. No bare `throw new Error(...)` is left in packages/commands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ♻️ refactor(api): drop the commands hand-mapping the filter replaces The commands controller caught CommandSlugAlreadyExistsError on creation and rethrew it as a ConflictException. The error now carries kind conflict, so DomainExceptionFilter answers the same 409 on its own and the branch was dead; it goes. The three not-found branches — a command looked up by id, its latest version, its versions list — each raised their own NotFoundException naming the command id. They now throw CommandNotFoundError, so a missing command answers the same message as the use cases rather than one naming its id, still a 404. GetCommandById already answers a command in another space with `{ recipe: null }`, so there was no second, wrong-space branch to collapse. The log-and-rethrow catch blocks across every route go too, along with the warn logs sitting right before the not-found throws: the filter records every thrown failure once, and each try/catch then reduced to `throw error`, so the wrapper goes with it. The request-shape BadRequestExceptions on batch delete stay: "recipeIds must be an array" and "recipeIds array cannot be empty". DeleteCommandsBatchUseCase does not validate its ids, so nothing else would refuse the body. The frontend reads only the status — isPackmindConflictError checks 409 on creation — so the new messages change nothing there. Specs assert CommandNotFoundError reaches the caller rather than the NotFoundException the controller used to raise, replace the bare "does not belong to space" errors with it, and add one for the slug conflict propagating as CommandSlugAlreadyExistsError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A rule removal or edit is applied by matching payload.targetId against
the rule's id, and compareStandardFields stamped every one of them with
the placeholder createRuleId('unresolved'). The local Markdown carries
no ids, so there was nothing else to put there.
StandardChangeProposalApplier filters on `rule.id !== targetId`, so the
placeholder matches nothing and the change is applied to nothing. With
`--no-review`, where proposals go straight to batchApply, that is the
whole story: the CLI prints "1 standard updated", drops the staged
change, and the rule is still there. Nothing retries it, because the
local playbook no longer knows anything is pending.
The CLI already had the missing piece: StandardsGateway.getRules returns
{ id, content } for a standard and was called from nowhere. buildProposals
now takes a fetcher, reads the rules once per standard, and passes a
content-to-id map down to compareStandardFields.
Left deliberately alone:
- StandardDiffStrategy keeps the placeholder. It feeds `playbook diff`,
which only prints, so no id is ever dereferenced.
- A rule whose content is not in the map keeps the placeholder too, so a
failed or partial lookup behaves as it did before rather than blocking
a submit. It now warns instead of reporting success in silence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
987poiuytrewq
force-pushed
the
fix/resolve-rule-ids-before-submit
branch
from
September 23, 2026 15:35
2748890 to
a41d51a
Compare
Author
|
Superseded by PackmindHub#515, which carries the same branch to upstream for review. Closing so there is a single place to review; the branch stays on this fork as the PR head. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation
Removing or editing a rule in a standard and submitting it with
--no-reviewreports success and changes nothing on the server. The rule is still there afterwards, and nothing retries it.compareStandardFieldsstamps everydeleteRuleandupdateRuleproposal with a placeholder id:createRuleIdisbrandedIdFactory(), which returns(id) => id, sotargetIdis the literal string'unresolved'. The local Markdown carries no rule ids, so there was nothing else to put there.StandardChangeProposalApplierresolves both change types by id:The placeholder matches no rule, so the filter removes nothing and
updateRule'smaprewrites nothing. On the--no-reviewpath, whereplaybookSubmitHandlersends proposals straight tobatchApply, that is the whole story:batchApplyreturns success, because the standard itself was touched.1 standard updatedfromcollectParts(response.updated).The rule survives, the operator is told it did not, and there is nothing left locally to retry. Rule additions are unaffected, which is why this reads as "some edits don't stick" rather than as a broken command.
The fix
The CLI already had the missing piece.
StandardsGateway.getRulesreturns{ id, content }[]for a standard and was called from nowhere in the codebase.buildProposalsnow takes an optional fetcher, reads a standard's rules once, and handscompareStandardFieldsa content-to-id map. No server change and no new endpoint.Two things left deliberately alone:
StandardDiffStrategykeeps the placeholder. It feedsplaybook diff, which only prints; no id is ever dereferenced there. Adding a network call to a display command seemed a poor trade.Type of Change
Affected Components
apps/clionly (application/utils/artifactComparison.ts,infra/commands/playbook/submit/proposalBuilder.ts,infra/commands/playbook/submitHandler.ts)Testing
Test Details:
Ten tests added across the two specs.
artifactComparison.spec.ts—compareStandardFields:proposalBuilder.spec.ts—buildProposals:The six that assert the new behaviour were confirmed to fail against
mainwith only the specs applied, so they pin the behaviour rather than the implementation. The fallback tests pass either way, by design.Verified against a live instance. Both CLIs were run against the same standard in a throwaway space, deleting the same rule with
--no-review, pulling afterwards to read the server's own state back:pull1 standard updated1 standard updatedEditing a rule was checked the same way and also lands on this branch, which matters because
matchUpdatedRulesturns a reworded rule into anupdateRulecarrying the same placeholder. The fixture was restored to its published state afterwards — by another--no-reviewdeletion, which is its own small proof.nx test packmind-cli— 126 suites, 3487 tests, all passing.nx typecheck packmind-cliclean.nx lint packmind-clireports the same 13 pre-existing warnings asmain, none in the changed files.prettier --checkclean.TODO List
Reviewer Notes
This is not scoped to
--no-review.buildProposalsruns before thenoReviewbranch, so the resolved ids now reachbatchCreateas well asbatchApply. That is deliberate, but it is the part I would most like a second opinion on, because I could not establish what the review path does withtargetIdtoday.What the public code shows:
StandardChangesApplier extends StandardChangeProposalApplier, so the review path ends in the samerules.filter((rule) => rule.id !== changeProposal.payload.targetId).changeProposal.payloaddirectly rather than throughgetEffectivePayload, so a reviewer'sdecisiondoes not supply the id either.StandardChangeProposalApplier.spec.tssupplies a realruleIdin itsdeleteRulefixtures.Taken alone that suggests a CLI-originated rule deletion could never have applied on either path, which does not match what we observe — deletions do land through review. So
batchCreatemost likely resolvestargetIdwhen the proposal is created. That code is in the closed edition (the OSSPlaybookChangeManagementAdapterthrowsMethod not implementedfor every method), so this is inference, not something I could read.Either way the change should be safe: if creation resolves the id, a correct id arriving pre-resolved is at worst redundant; if it does not, this repairs the review path too. One argument in favour of ids over content matching on this path specifically: a review proposal can sit pending for days, and
saveNewVersionpasses rule ids through toupdateStandard, so an id survives someone rewording that rule in the UI where a content key would not.The check I would ask of someone with access: submit a rule removal through review and look at the stored proposal's
payload.targetId. If it is the literal stringunresolved, the review path has the same defect and this fixes both.Two smaller notes:
matchUpdatedRulespairs a delete with an add into anupdateRule, so a merely reworded rule takes the same path and was equally affected.spaceId/standardIdand cached for the lifetime of onebuildProposalscall, so a standard rendered for several agents costs one request.🤖 Generated with Claude Code