fix(gitops): map an in-flight refusal during source save to 409

A PUT that changes the source policy while a fetch is in flight threw
GitOpsTransitionError out of the upsert transaction. The rollback was
correct, but the route mapped the throw to a generic 500, hiding the
actionable reason. Suspend and dismiss already translate this error to
an OPERATION_IN_FLIGHT GitSourceError; the save path now does the same,
and a route test pins the 409 and the full rollback.
This commit is contained in:
Anso
2026-09-09 18:50:54 -04:00
parent 71bdf744e5
commit dcc645c268
2 changed files with 41 additions and 2 deletions
@@ -2550,6 +2550,36 @@ describe('git-source policy compatibility', () => {
expect(res.body.error).toMatch(/source_policy/i);
});
it('PUT racing an in-flight operation maps the refusal to 409', async () => {
const stackName = 'policy-put-in-flight';
seedStackDir(stackName);
seedGitSource(stackName);
seedApp(stackName, 'automatic');
// Open a fetch on the application so the policy transition refuses.
// The PUT transaction must roll back whole, and the specific refusal
// must reach the operator as 409, not as a generic 500.
GitOpsTransitions.getInstance().fetchStarted(
`policy-app-${stackName}`,
{ operationId: 'fetch-policy-put', actor: 'test', trigger: 'poll', at: Date.now() },
);
const fetchFromGit = stubFetchFromGit();
try {
const res = await request(app)
.put(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`)
.send(putBody({ auto_apply_on_webhook: false, source_policy: 'review' }));
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/in flight/);
// The whole save rolled back: the policy did not change under the
// refused PUT.
const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName);
expect(application?.source_policy).toBe('automatic');
} finally {
fetchFromGit.mockRestore();
deleteRows(stackName);
}
});
it('read projects auto_apply_on_webhook true only for automatic', async () => {
const stackName = 'policy-read-projection';
seedStackDir(stackName);
+11 -2
View File
@@ -911,8 +911,11 @@ export class GitSourceService {
// The source row, the pending clear, and the GitOps transition commit
// together. Clearing pending without invalidating the candidate would
// leave the model offering an apply for files the operator can no
// longer produce.
db.getDb().transaction(() => {
// longer produce. A refused transition (an operation went in flight)
// aborts the whole save; it is mapped below so the operator sees the
// specific refusal, not a generic 500.
try {
db.getDb().transaction(() => {
db.upsertGitSource({
stack_name: input.stackName,
repo_url: input.repoUrl,
@@ -990,6 +993,12 @@ export class GitSourceService {
GitOpsTransitions.getInstance().sourcePolicyChanged(app.id, effectivePolicy, envelope);
}
})();
} catch (error) {
if (error instanceof GitOpsTransitionError) {
throw new GitSourceError('OPERATION_IN_FLIGHT', `Cannot save the Git source for ${input.stackName}: ${error.message}`);
}
throw error;
}
if (
input.authType === 'deploy_key'