Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
14dbdb5
refactor(semver): one parser for every version comparison (DOPE-448)
marconetsf Aug 6, 2026
7ad73f6
feat(vpp): enforce minEditorVersion when installing a package (DOPE-448)
marconetsf Aug 6, 2026
6547b87
feat(compile): block upload when a declared version floor is not met …
marconetsf Aug 6, 2026
7e57734
docs(compat): record the editor/runtime/VPP compatibility strategy (D…
marconetsf Aug 6, 2026
6f05240
Merge branch 'development' into feature/DOPE-448-version-compatibility
marconetsf Aug 6, 2026
97a1239
docs(compat): mark the strategy as shipped, not planned (DOPE-448)
marconetsf Aug 6, 2026
0603f61
Merge branch 'development' into feature/DOPE-448-version-compatibility
marconetsf Aug 6, 2026
a0fc3f3
Merge branch 'development' into feature/DOPE-448-version-compatibility
marconetsf Aug 6, 2026
db38d3d
fix(compat): one version parser, one comparator, one board lookup (DO…
thiagoralves Aug 6, 2026
4629e64
docs(compat): mark review items 3, 4 and 6 resolved (DOPE-448)
thiagoralves Aug 6, 2026
32d5636
fix(compat): reject version components too large to hold exactly (DOP…
thiagoralves Aug 6, 2026
de9143d
Merge branch 'development' into feature/DOPE-448-version-compatibility
marconetsf Aug 7, 2026
8a7ae51
fix(compat): tolerate an unreadable version floor on the load path (D…
marconetsf Aug 7, 2026
b7e5e65
Merge branch 'development' into feature/DOPE-448-version-compatibility
marconetsf Aug 7, 2026
df99a4e
Merge branch 'development' into feature/DOPE-448-version-compatibility
marconetsf Aug 7, 2026
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
473 changes: 473 additions & 0 deletions docs/version-compatibility-strategy.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,29 @@ describe('createEditorCompilerPlatformPort', () => {
const port = createEditorCompilerPlatformPort(makeHandlers({ handleVendorPluginPackaging }), makeContext())
const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined)
expect(handleVendorPluginPackaging).toHaveBeenCalledTimes(1)
expect(result).toEqual({ files: {} })
// No `getVppRuntimeFloor` in the default context, so no floor is known —
// which the pipeline reads as "no constraint".
expect(result).toEqual({ files: {}, minRuntimeVersion: null })
})

it('packageVppPlugin surfaces the VPP runtime floor when the context can resolve one', async () => {
const getVppRuntimeFloor = jest.fn(() => '4.1.9')
const port = createEditorCompilerPlatformPort(makeHandlers(), makeContext({ getVppRuntimeFloor }))
const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined)
expect(getVppRuntimeFloor).toHaveBeenCalledWith('SLM-RP4')
expect(result.minRuntimeVersion).toBe('4.1.9')
})

it('packageVppPlugin reports no floor when the resolver throws', async () => {
// A gate that failed the build because it could not read its own metadata
// would be worse than the mismatch it exists to catch.
const getVppRuntimeFloor = jest.fn(() => {
throw new Error('registry unreadable')
})
const port = createEditorCompilerPlatformPort(makeHandlers(), makeContext({ getVppRuntimeFloor }))
const result = await port.packageVppPlugin({ boardTarget: 'SLM-RP4' }, () => undefined)
expect(result.minRuntimeVersion).toBeNull()
expect(result.errors).toBeUndefined()
})

it('packageVppPlugin returns an errors[] when the handler throws', async () => {
Expand Down Expand Up @@ -327,7 +349,48 @@ describe('createEditorCompilerPlatformPort', () => {
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
() => undefined,
)
expect(result).toEqual({ ok: true, version: '4.1.2' })
// This stub answers every endpoint with a `/api/version` body, so
// `/api/capabilities` yields no usable `runtimeVersion` and the probe
// falls back — the exact shape of a runtime predating the endpoint.
expect(result).toEqual({ ok: true, version: '4.1.2', minEditorVersion: null })
})

it('checkRuntimeVersion reads the editor floor from /api/capabilities when the device serves it', async () => {
const makeRuntimeApiRequest = jest.fn(async (_ip: string, endpoint: string) => {
if (endpoint === '/api/capabilities') {
return { success: true as const, data: { runtimeVersion: 'v4.2.0', minEditorVersion: '4.2.1' } }
}
return { success: true as const, data: { version: 'SHOULD-NOT-BE-USED' } }
}) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest']
const port = createEditorCompilerPlatformPort(
makeHandlers(),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }),
)
const result = await port.checkRuntimeVersion(
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
() => undefined,
)
expect(result).toEqual({ ok: true, version: 'v4.2.0', minEditorVersion: '4.2.1' })
})

it('checkRuntimeVersion falls back to /api/version when capabilities 404s', async () => {
const makeRuntimeApiRequest = jest.fn(async (_ip: string, endpoint: string) => {
if (endpoint === '/api/capabilities') return { success: false as const, error: '404 Not Found' }
return { success: true as const, data: { version: 'v4.1.7' } }
}) as unknown as EditorCompilerPlatformPortContext['mainProcessBridge']['makeRuntimeApiRequest']
const log = jest.fn()
const port = createEditorCompilerPlatformPort(
makeHandlers(),
makeContext({ mainProcessBridge: { makeRuntimeApiRequest, makeRuntimeApiUpload: jest.fn() } }),
)
const result = await port.checkRuntimeVersion(
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
log,
)
expect(result).toEqual({ ok: true, version: 'v4.1.7', minEditorVersion: null })
// The 404 is the normal answer from every deployed runtime — it must not
// nag the user on every upload.
expect(log).not.toHaveBeenCalled()
})

it('checkRuntimeVersion returns version=null and logs a warning on probe failure', async () => {
Expand All @@ -344,7 +407,7 @@ describe('createEditorCompilerPlatformPort', () => {
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
log,
)
expect(result).toEqual({ ok: true, version: null })
expect(result).toEqual({ ok: true, version: null, minEditorVersion: null })
expect(log).toHaveBeenCalledWith(expect.stringContaining('Could not reach runtime'), 'warning')
})

Expand All @@ -361,7 +424,7 @@ describe('createEditorCompilerPlatformPort', () => {
{ context: { kind: 'editor-https', ip: '10.0.0.1', jwt: 'token' } },
log,
)
expect(result).toEqual({ ok: true, version: null })
expect(result).toEqual({ ok: true, version: null, minEditorVersion: null })
expect(log).toHaveBeenCalledWith(expect.stringContaining('probe blew up'), 'warning')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,28 @@ jest.mock('electron', () => ({
MessageChannelMain: class {},
}))

type FindVppDevice = typeof import('../../../shared/hardware/find-vpp-device')

const listInstalled = jest.fn()
const getInstalledPackageManifest = jest.fn()
jest.mock('../../package-manager', () => ({
PackageManagerModule: jest.fn().mockImplementation(() => ({
listInstalled,
getInstalledPackageManifest,
})),
PackageManagerModule: jest.fn().mockImplementation(() => {
const port = { listInstalled, getInstalledPackageManifest }
return {
...port,
// Board lookup runs through the shared `findVppDeviceByBoardName`, and
// the mock runs the real one over these two stubs rather than
// re-implementing the search — a stub that resolved boards its own way
// would let the production lookup change without a test noticing.
// `require` (not a top-level import) because jest.mock factories are
// hoisted above the import block.
findDeviceByBoardName: (boardName: string) =>
(jest.requireActual('../../../shared/hardware/find-vpp-device') as FindVppDevice).findVppDeviceByBoardName(
port,
boardName,
),
}
}),
}))

// eslint-disable-next-line import/first
Expand Down
57 changes: 21 additions & 36 deletions src/backend/editor/compiler/compiler-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,14 @@
buildModuleConfigEntries,
generateVendorPluginConfig,
} from '@root/backend/shared/utils/vpp/generate-vendor-plugin-config'
import { APP_VERSION } from '@root/frontend/data/constants/app-version'
import { getErrorMessage } from '@root/frontend/utils/get-error-message'
import { app as electronApp, dialog, MessageChannelMain } from 'electron'
import type { MessagePortMain } from 'electron/main'
import JSZip from 'jszip'

import type { PlatformOption } from '../../../middleware/shared/ports/types'
import { BoardInfoResolver } from '../../shared/hardware/board-info-resolver'
import type { PackageManifest } from '../package-manager'
import { PackageManagerModule } from '../package-manager'
import { CreateXMLFile } from '../utils'
import { createDesktopLibraryBuildPort } from './desktop-library-build-port'
Expand Down Expand Up @@ -491,8 +491,8 @@

checkStrucppAvailability(): MethodsResult<string> {
try {
const { getVersion } = loadStrucpp()

Check warning on line 494 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe array destructuring of a tuple element with an error typed value
return { success: true, data: getVersion() }

Check warning on line 495 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe call of a(n) `error` type typed value

Check warning on line 495 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
} catch {
throw new Error('STruC++ not available. Run "npm run setup:binaries" to install it.')
}
Expand Down Expand Up @@ -2011,28 +2011,16 @@
handleOutputData: HandleOutputDataCallback,
): Promise<void> {
try {
const packageManager = new PackageManagerModule()
const installed = packageManager.listInstalled()

let matchingPackagePath: string | null = null
let matchingDevice: PackageManifest['devices'][number] | null = null

for (const pkg of installed) {
const manifest = packageManager.getInstalledPackageManifest(pkg.packageId)
if (!manifest) continue
const device = manifest.devices.find((d) => d.name === boardTarget)
if (device) {
matchingPackagePath = pkg.path
matchingDevice = device
break
}
}
const match = new PackageManagerModule().findDeviceByBoardName(boardTarget)

if (!matchingDevice || !matchingPackagePath) {
if (!match) {
handleOutputData(`Board "${boardTarget}" is not from a VPP package, skipping VPP packaging`, 'info')
return
}

const matchingPackagePath = match.pkg.path
const matchingDevice = match.device

if (matchingDevice.target.type !== 'runtime-v4') {
handleOutputData(
`VPP board "${boardTarget}" is not runtime-v4 (target=${matchingDevice.target.type}), skipping VPP packaging`,
Expand Down Expand Up @@ -2289,26 +2277,12 @@
vendorScreenData: Record<string, unknown>,
): Promise<Array<{ slot: number; bytes: number[] }>> {
try {
const packageManager = new PackageManagerModule()
const installed = packageManager.listInstalled()

let matchingPackagePath: string | null = null
let matchingDevice: PackageManifest['devices'][number] | null = null
for (const pkg of installed) {
const manifest = packageManager.getInstalledPackageManifest(pkg.packageId)
if (!manifest) continue
const device = manifest.devices.find((d) => d.name === boardTarget)
if (device) {
matchingPackagePath = pkg.path
matchingDevice = device
break
}
}
const match = new PackageManagerModule().findDeviceByBoardName(boardTarget)

const rawModules = matchingDevice?.moduleSystem?.modules
if (!matchingDevice || !matchingPackagePath || !rawModules || rawModules.length === 0) return []
const rawModules = match?.device.moduleSystem?.modules
if (!match || !rawModules || rawModules.length === 0) return []

const pkgPath = matchingPackagePath
const pkgPath = match.pkg.path
const modules = await Promise.all(
rawModules.map(async (m) => {
let configScreenDefinition: unknown
Expand Down Expand Up @@ -2634,6 +2608,11 @@
cleanBuild: cleanBuild ?? false,
mainProcessBridge,
compressSourceFolder: (folderPath: string) => this.compressSourceFolder(folderPath),
// VPP runtime floor (DOPE-448). Constructed per call rather than
// held on the class because the registry is read off disk and may
// have changed since the last compile (a package installed or
// removed mid-session).
getVppRuntimeFloor: (board: string) => new PackageManagerModule().getRuntimeFloorForBoard(board),
pollTimeoutMs: CompilerModule.COMPILATION_STATUS_TIMEOUT_MS,
pollIntervalMs: CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS,
startTimeoutMs: POST_BUILD_START_TIMEOUT_MS,
Expand Down Expand Up @@ -2716,6 +2695,12 @@
communicationPort: communicationPort ?? undefined,
...(vppModbusState ? { vppModbusState } : {}),
vendorScreenData: effectiveVendorScreenData,
// Compared against the `minEditorVersion` a runtime publishes at
// `/api/capabilities` (DOPE-448). Injected because the pipeline
// lives in `backend/shared/`, which the layer rules keep out of
// `frontend/data/` — which build is running is a fact about the
// host app, not about the compile.
editorVersion: APP_VERSION,
},
platformPort,
(event) => {
Expand Down Expand Up @@ -2895,7 +2880,7 @@
_mainProcessPort.postMessage({
logLevel,
message: data,
...(compileError ? { compileError } : {}),

Check warning on line 2883 in src/backend/editor/compiler/compiler-module.ts

View workflow job for this annotation

GitHub Actions / lint / Lint Check

Unsafe assignment of an error typed value
})
},
{ hasCBlocks, pous: knownPous, libraries, missingLibraries },
Expand Down
75 changes: 61 additions & 14 deletions src/backend/editor/compiler/editor-compiler-platform-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ export interface EditorCompilerPlatformPortContext {
* in the `archiver`-dependent compressSourceFolder method (which
* has its own private state on CompilerModule). */
compressSourceFolder: (folderPath: string) => Promise<Buffer>
/**
* `package.minRuntimeVersion` of the VPP providing a given board, or
* null when the board isn't from a VPP / declares no floor
* (DOPE-448). The pipeline compares it against the connected
* runtime after the version probe.
*
* Injected rather than resolved here for the same reason as
* `compressSourceFolder`: importing `PackageManagerModule` directly
* pulls in the Electron-dependent logger at module load, which
* breaks anything importing this adapter outside a real Electron
* process (its own unit test included).
*
* Optional so callers predating this stay valid; absent means "no
* floor known", which the pipeline treats as no constraint.
*/
getVppRuntimeFloor?: (boardTarget: string) => string | null
/** Timeout for the post-upload compile-status poll. */
pollTimeoutMs: number
/** Interval for the post-upload compile-status poll. */
Expand Down Expand Up @@ -503,8 +519,14 @@ export function createEditorCompilerPlatformPort(
},

/**
* Probe the device's `/api/version` (unauthenticated) so the
* pipeline can short-circuit uploads to pre-4.1.0 runtimes.
* Probe the device (unauthenticated) so the pipeline can
* short-circuit uploads in both directions: to a runtime too old
* for this editor, and from an editor too old for this runtime.
*
* Tries `/api/capabilities` first — it carries the runtime version
* AND the runtime's `minEditorVersion` in one round-trip — and
* falls back to `/api/version` for runtimes that predate it
* (DOPE-448).
*
* Transport: Electron's HTTPS bridge → device IP.
* Response parsing + null-fallback live in the shared
Expand All @@ -513,19 +535,21 @@ export function createEditorCompilerPlatformPort(
*/
async checkRuntimeVersion(args: CheckRuntimeVersionArgs, log: PlatformLog): Promise<CheckRuntimeVersionResult> {
const deviceContext = assertEditorHttpsContext(args.context)
const { version } = await probeRuntimeVersion({
fetchVersion: async () => {
const result = await context.mainProcessBridge.makeRuntimeApiRequest<{ version: string }>(
deviceContext.ip,
'/api/version',
(data: string) => JSON.parse(data) as { version: string },
)
if (!result.success) return { success: false, error: result.error }
return { success: true, body: result.data }
},
const getJson = async (endpoint: string) => {
const result = await context.mainProcessBridge.makeRuntimeApiRequest<unknown>(
deviceContext.ip,
endpoint,
(data: string) => JSON.parse(data) as unknown,
)
if (!result.success) return { success: false as const, error: result.error }
return { success: true as const, body: result.data }
}
const { version, minEditorVersion } = await probeRuntimeVersion({
fetchCapabilities: () => getJson('/api/capabilities'),
fetchVersion: () => getJson('/api/version'),
log,
})
return { ok: true, version }
return { ok: true, version, minEditorVersion }
},

/**
Expand Down Expand Up @@ -568,7 +592,12 @@ export function createEditorCompilerPlatformPort(
log(message, logLevel ?? 'info')
},
)
return { files: {} }
// Surface the package's runtime floor so the pipeline can compare
// it against the connected runtime after the version probe
// (DOPE-448). Read here rather than inside the handler because
// the handler returns void and writes straight to disk; the
// registry lookup is cheap next to the packaging work that ran.
return { files: {}, minRuntimeVersion: readVppRuntimeFloor(context, args.boardTarget) }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
Expand Down Expand Up @@ -598,6 +627,24 @@ export function assertEditorHttpsContext(
return context
}

/**
* `package.minRuntimeVersion` of the VPP providing `boardTarget`, or
* null when no resolver was injected, the board is not from a VPP, is
* not a `runtime-v4` target, or the package declares no floor.
*
* Never throws: a missing or unreadable registry means "no declared
* floor", which the pipeline treats as no constraint. A version gate
* that failed the build because it could not read its own metadata
* would be worse than the mismatch it exists to catch.
*/
function readVppRuntimeFloor(context: EditorCompilerPlatformPortContext, boardTarget: string): string | null {
try {
return context.getVppRuntimeFloor?.(boardTarget) ?? null
} catch {
return null
}
}

/**
* Find the arduino-cli-produced `Baremetal.ino.hex` under the build
* directory. arduino-cli writes it to a board-FQBN-specific
Expand Down
Loading
Loading