App installation workspace version check engines constraint (#22613)

## What

Makes the app-installation version gate **workspace-scoped**. App
installation now validates a manifest's `engines.twenty` requirement
against the version the **target workspace has actually finished
upgrading to**, instead of the instance/server's inferred version.

## Why

The server binary and a given workspace's migration state can diverge.
In a multi-workspace deployment the instance can already report version
`X` while an individual workspace still hasn't completed its
workspace-scoped upgrade commands for `X` (it's mid-upgrade or a
migration failed). Gating on the instance version let an app that
requires `X` install into a workspace whose schema/metadata is
effectively still at `X-1`, which can break the app. The requirement
should be checked against what the *workspace* has completed, not what
the server reports.

## How

- **`UpgradeStatusService.getWorkspaceCompletedVersion(workspaceId)`**
(new): resolves the last fully-completed upgrade version for a workspace
by reading its upgrade cursor and walking the upgrade sequence:
- Returns the cursor's version when the cursor sits on the **last step
of its version segment** and its status is `completed`.
- Otherwise walks backwards to the previous fully-completed version
segment.
- Returns `null` when the cursor is missing, not found in the sequence,
or otherwise uninterpretable.
- **`ApplicationVersionValidationService`**:
- Adds `validateWorkspaceCompatibility({ requiredServerVersion,
workspaceId })`.
- Extracts the shared semver logic into a private
`validateVersionAgainstRange({ version, requiredVersionRange, scope })`
and makes error messages scope-aware (workspace vs. instance).
`validateServerCompatibility` is preserved and now delegates to it.
  - New failure reason `INVALID_WORKSPACE_VERSION`.
- **`ApplicationInstallService`** now calls
`validateWorkspaceCompatibility` with the `workspaceId` instead of
`validateServerCompatibility`.
- **Exception plumbing**: new
`ApplicationExceptionCode.INVALID_WORKSPACE_VERSION`, surfaced as a
`UserInputError` (`BAD_USER_INPUT`) with a user-friendly message ("This
workspace's upgrade state could not be determined…"). The
tarball/registration path maps it onto the existing
`INVALID_SERVER_VERSION` registration code.

## Notes

- **Publishing (app registration) is intentionally not
workspace-gated.** The tarball/registration path
(`ApplicationTarballService`) still uses the instance-level
`validateServerCompatibility` check, not the new workspace-scoped one.
Publishing an app is not tied to any particular workspace's upgrade
state, so there is no workspace version to check at that point — the
workspace-completed-version gate only applies when installing an app
into a specific workspace.

## Testing

- Unit tests for `ApplicationVersionValidationService`
(`validateServerCompatibility` + new `validateWorkspaceCompatibility`)
covering: no requirement, invalid semver range, satisfied/unsatisfied
ranges, and the uninterpretable-cursor case.
- Unit tests for `UpgradeStatusService.getWorkspaceCompletedVersion`
against a three-segment mock upgrade sequence (multi-command version,
instance-only version, workspace-terminated version).
- New integration suite
`failing-app-installation-workspace-version.integration-spec.ts` (+
snapshots) exercising the real install flow: rejects installation when
the workspace hasn't completed the required version, and when the
workspace's upgrade cursor can't be interpreted. Adds a
`create-app-tarball.util.ts` test helper.
This commit is contained in:
Paul Rastoin
2026-07-07 12:05:06 +02:00
committed by GitHub
parent cabe5545ae
commit 628ab153a8
19 changed files with 849 additions and 45 deletions
@@ -11,14 +11,26 @@ import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/servi
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const LAST_INSTANCE_COMMAND = '1.23.0_LastInstanceCommand_1780000002000';
const LAST_WORKSPACE_COMMAND = '1.23.0_LastWorkspaceCommand_1780000003000';
const EARLIER_COMMAND = '1.22.0_EarlierCommand_1776000001000';
const V1_21_INSTANCE_COMMAND = '1.21.0_InstanceCommand_1772000001000';
const V1_21_SLOW_INSTANCE_COMMAND = '1.21.0_SlowInstanceCommand_1772000002000';
const V1_21_FIRST_WORKSPACE_COMMAND =
'1.21.0_FirstWorkspaceCommand_1772000003000';
const V1_21_SECOND_WORKSPACE_COMMAND =
'1.21.0_SecondWorkspaceCommand_1772000004000';
const V1_22_INSTANCE_COMMAND = '1.22.0_InstanceCommand_1776000001000';
const V1_23_INSTANCE_COMMAND = '1.23.0_InstanceCommand_1780000002000';
const V1_23_WORKSPACE_COMMAND = '1.23.0_WorkspaceCommand_1780000003000';
// Three version segments: 1.21.0 has multiple workspace commands, 1.22.0 is
// instance-only, 1.23.0 ends the sequence with a workspace command.
const MOCK_SEQUENCE = [
{ kind: 'fast-instance', name: EARLIER_COMMAND },
{ kind: 'fast-instance', name: LAST_INSTANCE_COMMAND },
{ kind: 'workspace', name: LAST_WORKSPACE_COMMAND },
{ kind: 'fast-instance', name: V1_21_INSTANCE_COMMAND },
{ kind: 'slow-instance', name: V1_21_SLOW_INSTANCE_COMMAND },
{ kind: 'workspace', name: V1_21_FIRST_WORKSPACE_COMMAND },
{ kind: 'workspace', name: V1_21_SECOND_WORKSPACE_COMMAND },
{ kind: 'fast-instance', name: V1_22_INSTANCE_COMMAND },
{ kind: 'fast-instance', name: V1_23_INSTANCE_COMMAND },
{ kind: 'workspace', name: V1_23_WORKSPACE_COMMAND },
];
type WorkspaceRecord = {
@@ -131,7 +143,7 @@ describe('UpgradeStatusService', () => {
describe('getInstanceStatus', () => {
it('should return up-to-date when cursor is at last instance command', async () => {
getLastAttemptedInstanceCommand.mockResolvedValue({
name: LAST_INSTANCE_COMMAND,
name: V1_23_INSTANCE_COMMAND,
status: 'completed',
executedByVersion: '1.23.0',
errorMessage: null,
@@ -146,7 +158,7 @@ describe('UpgradeStatusService', () => {
it('should return behind when cursor is before last instance command', async () => {
getLastAttemptedInstanceCommand.mockResolvedValue({
name: EARLIER_COMMAND,
name: V1_22_INSTANCE_COMMAND,
status: 'completed',
executedByVersion: '1.22.0',
errorMessage: null,
@@ -161,7 +173,7 @@ describe('UpgradeStatusService', () => {
it('should return failed when latest instance command failed', async () => {
getLastAttemptedInstanceCommand.mockResolvedValue({
name: LAST_INSTANCE_COMMAND,
name: V1_23_INSTANCE_COMMAND,
status: 'failed',
executedByVersion: '1.23.0',
errorMessage: 'column does not exist',
@@ -185,6 +197,131 @@ describe('UpgradeStatusService', () => {
});
});
describe('getWorkspaceCompletedVersion', () => {
const mockWorkspaceCursor = (
cursor: { name: string; status: 'completed' | 'failed' } | null,
) => {
getWorkspaceLastAttemptedCommandName.mockResolvedValue(
cursor === null
? new Map()
: new Map([
[
'ws-1',
{
workspaceId: 'ws-1',
...cursor,
executedByVersion: '1.23.0',
errorMessage: null,
createdAt: new Date('2025-06-01T00:00:00Z'),
isInitial: false,
},
],
]),
);
};
it('should return the cursor version when at the last step of its segment with completed status', async () => {
mockWorkspaceCursor({
name: V1_23_WORKSPACE_COMMAND,
status: 'completed',
});
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
'1.23.0',
);
});
it('should return the immediately previous version when the cursor is mid-segment', async () => {
mockWorkspaceCursor({
name: V1_23_INSTANCE_COMMAND,
status: 'completed',
});
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
'1.22.0',
);
});
it('should return the previous version when the last step of the segment failed', async () => {
mockWorkspaceCursor({ name: V1_23_WORKSPACE_COMMAND, status: 'failed' });
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
'1.22.0',
);
});
it('should return the cursor version when completed at the end of an instance-only segment', async () => {
mockWorkspaceCursor({
name: V1_22_INSTANCE_COMMAND,
status: 'completed',
});
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
'1.22.0',
);
});
it('should return the previous version when an instance-only segment failed', async () => {
mockWorkspaceCursor({ name: V1_22_INSTANCE_COMMAND, status: 'failed' });
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
'1.21.0',
);
});
it('should return the cursor version when completed at the last of several workspace commands', async () => {
mockWorkspaceCursor({
name: V1_21_SECOND_WORKSPACE_COMMAND,
status: 'completed',
});
await expect(service.getWorkspaceCompletedVersion('ws-1')).resolves.toBe(
'1.21.0',
);
});
it('should not consider a segment completed while earlier workspace commands of the same segment remain', async () => {
mockWorkspaceCursor({
name: V1_21_FIRST_WORKSPACE_COMMAND,
status: 'completed',
});
await expect(
service.getWorkspaceCompletedVersion('ws-1'),
).resolves.toBeNull();
});
it('should return null when the first segment failed with no previous segment', async () => {
mockWorkspaceCursor({
name: V1_21_SECOND_WORKSPACE_COMMAND,
status: 'failed',
});
await expect(
service.getWorkspaceCompletedVersion('ws-1'),
).resolves.toBeNull();
});
it('should return null when the workspace has no cursor', async () => {
mockWorkspaceCursor(null);
await expect(
service.getWorkspaceCompletedVersion('ws-1'),
).resolves.toBeNull();
});
it('should return null when the cursor is outside the supported sequence', async () => {
mockWorkspaceCursor({
name: '1.10.0_OutOfSequenceCommand_1700000000000',
status: 'completed',
});
await expect(
service.getWorkspaceCompletedVersion('ws-1'),
).resolves.toBeNull();
});
});
describe('getWorkspaceStatuses', () => {
it('should return up-to-date for workspace at last command', async () => {
mockActiveWorkspaces([{ id: 'ws-1', displayName: 'Apple' }]);
@@ -195,7 +332,7 @@ describe('UpgradeStatusService', () => {
'ws-1',
{
workspaceId: 'ws-1',
name: LAST_WORKSPACE_COMMAND,
name: V1_23_WORKSPACE_COMMAND,
status: 'completed',
executedByVersion: '1.23.0',
errorMessage: null,
@@ -223,7 +360,7 @@ describe('UpgradeStatusService', () => {
'ws-1',
{
workspaceId: 'ws-1',
name: LAST_WORKSPACE_COMMAND,
name: V1_23_WORKSPACE_COMMAND,
status: 'completed',
executedByVersion: '1.23.0',
errorMessage: null,
@@ -234,7 +371,7 @@ describe('UpgradeStatusService', () => {
'ws-2',
{
workspaceId: 'ws-2',
name: EARLIER_COMMAND,
name: V1_22_INSTANCE_COMMAND,
status: 'completed',
executedByVersion: '1.22.0',
errorMessage: null,
@@ -282,7 +419,7 @@ describe('UpgradeStatusService', () => {
cacheGetFailedWorkspaceIds.mockResolvedValue(['ws-3']);
cacheGetUpToDateWorkspaceCount.mockResolvedValue(5);
getLastAttemptedInstanceCommand.mockResolvedValue({
name: LAST_INSTANCE_COMMAND,
name: V1_23_INSTANCE_COMMAND,
status: 'completed',
executedByVersion: '1.23.0',
errorMessage: null,
@@ -354,7 +491,7 @@ describe('UpgradeStatusService', () => {
'ws-1',
{
workspaceId: 'ws-1',
name: LAST_WORKSPACE_COMMAND,
name: V1_23_WORKSPACE_COMMAND,
status: 'completed',
executedByVersion: '1.23.0',
errorMessage: null,
@@ -365,7 +502,7 @@ describe('UpgradeStatusService', () => {
'ws-2',
{
workspaceId: 'ws-2',
name: EARLIER_COMMAND,
name: V1_22_INSTANCE_COMMAND,
status: 'completed',
executedByVersion: '1.22.0',
errorMessage: null,
@@ -376,7 +513,7 @@ describe('UpgradeStatusService', () => {
'ws-3',
{
workspaceId: 'ws-3',
name: LAST_WORKSPACE_COMMAND,
name: V1_23_WORKSPACE_COMMAND,
status: 'failed',
executedByVersion: '1.23.0',
errorMessage: 'boom',
@@ -10,6 +10,7 @@ import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/service
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { UpgradeStatusCacheService } from 'src/engine/core-modules/upgrade/services/upgrade-status-cache.service';
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
import { extractVersionFromCommandName } from 'src/engine/core-modules/upgrade/utils/extract-version-from-command-name.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { In, Repository } from 'typeorm';
@@ -138,6 +139,54 @@ export class UpgradeStatusService {
);
}
async getWorkspaceCompletedVersion(
workspaceId: string,
): Promise<string | null> {
const cursors =
await this.upgradeMigrationService.getWorkspaceLastAttemptedCommandName([
workspaceId,
]);
const cursor = cursors.get(workspaceId);
if (!isDefined(cursor)) {
return null;
}
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
const cursorIndex = sequence.findIndex((step) => step.name === cursor.name);
if (cursorIndex === -1) {
return null;
}
const cursorVersion = extractVersionFromCommandName(cursor.name);
if (!isDefined(cursorVersion)) {
return null;
}
const isLastStepOfItsVersion =
cursorIndex === sequence.length - 1 ||
extractVersionFromCommandName(sequence[cursorIndex + 1].name) !==
cursorVersion;
if (cursor.status === 'completed' && isLastStepOfItsVersion) {
return cursorVersion;
}
for (let stepIndex = cursorIndex - 1; stepIndex >= 0; stepIndex--) {
const stepVersion = extractVersionFromCommandName(
sequence[stepIndex].name,
);
if (stepVersion !== cursorVersion) {
return stepVersion;
}
}
return null;
}
async getInstanceAndAllWorkspacesStatus(): Promise<InstanceAndAllWorkspacesUpgradeStatus> {
const computedAt = await this.upgradeStatusCacheService.getComputedAt();