fix(updater): prevent installation of non-newer versions and enhance update notifications

- Added checks to ensure that only newer versions are installed during the update process.
- Updated the notification logic to suppress alerts for non-newer updates.
- Introduced a new method to compare version numbers, improving version management in the UpdaterService.
- Enhanced the release workflow by removing unnecessary file uploads and adding canonical updater metadata publishing for better asset management.
This commit is contained in:
iliya 2026-03-30 15:24:23 +03:00
parent e4c9a100f3
commit f08885d58f
9 changed files with 362 additions and 32 deletions

View file

@ -143,7 +143,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
for f in release/*.dmg release/*.zip release/*.blockmap release/*.yml; do
for f in release/*.dmg release/*.zip release/*.blockmap; do
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
done
@ -213,7 +213,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
for f in release/*.exe release/*.blockmap release/*.yml; do
for f in release/*.exe release/*.blockmap; do
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
done
@ -285,7 +285,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap release/*.yml; do
for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap; do
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
done
@ -326,3 +326,85 @@ jobs:
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber
rm -f "$STABLE_NAME"
done
- name: Publish canonical updater metadata
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF#refs/tags/v}"
TAG="v${VERSION}"
REPO="${GITHUB_REPOSITORY}"
RELEASE_DATE="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
TMP_DIR="$(mktemp -d)"
cd "$TMP_DIR"
sha512_base64() {
openssl dgst -sha512 -binary "$1" | openssl base64 -A
}
file_size() {
wc -c < "$1" | tr -d '[:space:]'
}
download_asset() {
local name="$1"
curl -fSL -o "$name" "https://github.com/${REPO}/releases/download/${TAG}/${name}"
}
# Canonical Windows feed
download_asset "Claude-Agent-Teams-UI-Setup.exe"
WIN_SHA="$(sha512_base64 Claude-Agent-Teams-UI-Setup.exe)"
WIN_SIZE="$(file_size Claude-Agent-Teams-UI-Setup.exe)"
cat > latest.yml <<EOF
version: ${VERSION}
files:
- url: Claude-Agent-Teams-UI-Setup.exe
sha512: ${WIN_SHA}
size: ${WIN_SIZE}
path: Claude-Agent-Teams-UI-Setup.exe
sha512: ${WIN_SHA}
releaseDate: '${RELEASE_DATE}'
EOF
# Canonical Linux feed
download_asset "Claude-Agent-Teams-UI.AppImage"
LINUX_SHA="$(sha512_base64 Claude-Agent-Teams-UI.AppImage)"
LINUX_SIZE="$(file_size Claude-Agent-Teams-UI.AppImage)"
cat > latest-linux.yml <<EOF
version: ${VERSION}
files:
- url: Claude-Agent-Teams-UI.AppImage
sha512: ${LINUX_SHA}
size: ${LINUX_SIZE}
path: Claude-Agent-Teams-UI.AppImage
sha512: ${LINUX_SHA}
releaseDate: '${RELEASE_DATE}'
EOF
# Canonical macOS feed.
# electron-updater consumes only one latest-mac.yml asset on GitHub releases.
# We publish the x64 feed here because it works on Intel Macs and remains
# installable on Apple Silicon via Rosetta until we add arch-specific feed
# selection or universal packaging.
download_asset "Claude.Agent.Teams.UI-${VERSION}-mac.zip"
download_asset "Claude.Agent.Teams.UI-${VERSION}.dmg"
MAC_ZIP_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
MAC_ZIP_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}-mac.zip)"
MAC_DMG_SHA="$(sha512_base64 Claude.Agent.Teams.UI-${VERSION}.dmg)"
MAC_DMG_SIZE="$(file_size Claude.Agent.Teams.UI-${VERSION}.dmg)"
cat > latest-mac.yml <<EOF
version: ${VERSION}
files:
- url: Claude.Agent.Teams.UI-${VERSION}-mac.zip
sha512: ${MAC_ZIP_SHA}
size: ${MAC_ZIP_SIZE}
- url: Claude.Agent.Teams.UI-${VERSION}.dmg
sha512: ${MAC_DMG_SHA}
size: ${MAC_DMG_SIZE}
path: Claude.Agent.Teams.UI-${VERSION}-mac.zip
sha512: ${MAC_ZIP_SHA}
releaseDate: '${RELEASE_DATE}'
EOF
gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber

View file

@ -13,11 +13,12 @@
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import electronUpdater from 'electron-updater';
const { autoUpdater } = electronUpdater;
import { net } from 'electron';
import { app, net } from 'electron';
import type { UpdaterStatus } from '@shared/types';
import type { BrowserWindow } from 'electron';
@ -64,6 +65,7 @@ async function assetExists(url: string): Promise<boolean> {
export class UpdaterService {
private mainWindow: BrowserWindow | null = null;
private periodicTimer: ReturnType<typeof setInterval> | null = null;
private downloadedVersion: string | null = null;
constructor() {
autoUpdater.autoDownload = false;
@ -109,6 +111,17 @@ export class UpdaterService {
* isForceRunAfter=true launches the app after install. Other platforms ignore these.
*/
quitAndInstall(): void {
if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) {
logger.warn(
`Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}`
);
this.sendStatus({
type: 'error',
error: 'Refused to install a non-newer app version.',
});
return;
}
autoUpdater.quitAndInstall(true, true);
}
@ -137,6 +150,10 @@ export class UpdaterService {
safeSendToRenderer(this.mainWindow, 'updater:status', status);
}
private isNewerThanCurrent(candidateVersion: string): boolean {
return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion));
}
/**
* Verify that the platform-specific asset exists before notifying the renderer.
* If CI hasn't finished uploading the artifact for this OS yet, suppress the
@ -146,6 +163,13 @@ export class UpdaterService {
version: string;
releaseNotes?: string | unknown;
}): Promise<void> {
if (!this.isNewerThanCurrent(info.version)) {
logger.warn(
`Suppressing non-newer update notification. current=${app.getVersion()} candidate=${info.version}`
);
return;
}
const url = getExpectedAssetUrl(info.version);
if (url) {
const exists = await assetExists(url);
@ -192,6 +216,14 @@ export class UpdaterService {
});
autoUpdater.on('update-downloaded', (info) => {
if (!this.isNewerThanCurrent(info.version)) {
logger.warn(
`Ignoring downloaded non-newer update. current=${app.getVersion()} downloaded=${info.version}`
);
return;
}
this.downloadedVersion = info.version;
logger.info('Update downloaded:', info.version);
this.sendStatus({
type: 'downloaded',

View file

@ -1695,6 +1695,28 @@ export class TeamProvisioningService {
return Array.isArray(innerContent) ? (innerContent as Record<string, unknown>[]) : [];
}
private hasCapturedVisibleMessageToUser(content: Record<string, unknown>[]): boolean {
return content.some((part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'tool_use' || typeof part.name !== 'string') return false;
// Only native SendMessage(to="user") is guaranteed to be materialized as a
// visible outbound message by captureSendMessages().
// Keep this intentionally narrower than captureSendMessages(): if another tool path
// later starts creating its own user-visible row, expand this helper in lockstep.
if (part.name !== 'SendMessage') return false;
const input = part.input;
if (!input || typeof input !== 'object') return false;
const inp = input as Record<string, unknown>;
const target = (
typeof inp.recipient === 'string' ? inp.recipient : typeof inp.to === 'string' ? inp.to : ''
).trim();
return target.toLowerCase() === 'user';
});
}
private async matchCrossTeamLeadInboxMessages(
teamName: string,
leadName: string,
@ -5142,14 +5164,7 @@ export class TeamProvisioningService {
if (msg.type === 'assistant') {
const content = this.extractStreamContentBlocks(msg);
const hasCapturedSendMessage = content.some((part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
const input = part.input;
if (!input || typeof input !== 'object') return false;
const recipient = (input as Record<string, unknown>).recipient;
return typeof recipient === 'string' && recipient.trim().length > 0;
});
const hasCapturedVisibleMessageToUser = this.hasCapturedVisibleMessageToUser(content);
const textParts = content
.filter((part) => part.type === 'text' && typeof part.text === 'string')
@ -5170,26 +5185,27 @@ export class TeamProvisioningService {
run.provisioningOutputParts.push(text);
}
if (run.leadRelayCapture) {
// Once relay capture is settled, later assistant chunks belong to the normal live
// message flow. Keeping them in the capture branch would drop them on the floor
// until relayLeadInboxMessages() finally clears run.leadRelayCapture.
if (run.leadRelayCapture && !run.leadRelayCapture.settled) {
const capture = run.leadRelayCapture;
if (!capture.settled) {
capture.textParts.push(text);
if (capture.idleHandle) {
clearTimeout(capture.idleHandle);
}
capture.idleHandle = setTimeout(() => {
const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined);
}, capture.idleMs);
capture.textParts.push(text);
if (capture.idleHandle) {
clearTimeout(capture.idleHandle);
}
capture.idleHandle = setTimeout(() => {
const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined);
}, capture.idleMs);
} else if (run.provisioningComplete) {
// Push each assistant text block as a separate live message (per-message pattern).
// When the same assistant message includes SendMessage(...), skip text —
// When the same assistant message includes a user-visible message send, skip text —
// captureSendMessages() handles the visible outbound message separately.
if (
!run.silentUserDmForward &&
!run.suppressPostCompactReminderOutput &&
!hasCapturedSendMessage
!hasCapturedVisibleMessageToUser
) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
@ -5203,7 +5219,7 @@ export class TeamProvisioningService {
} else {
// Pre-ready: keep showing provisioning narration in the banner, but also mirror it
// into the live cache so Messages/Activity can show the earliest assistant output.
if (!run.silentUserDmForward && !hasCapturedSendMessage) {
if (!run.silentUserDmForward && !hasCapturedVisibleMessageToUser) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
this.pushLiveLeadTextMessage(
@ -6530,6 +6546,15 @@ export class TeamProvisioningService {
this.aliveRunByTeam.set(run.teamName, run.runId);
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
// Force a post-ready detail refresh so Messages reload persisted lead_session
// texts from JSONL even if the last visible assistant output only reached disk.
this.teamChangeEmitter?.({
type: 'lead-message',
teamName: run.teamName,
runId: run.runId,
detail: 'lead-session-sync',
});
// Fire "Team Launched" notification
void this.fireTeamLaunchedNotification(run);
@ -6629,6 +6654,15 @@ export class TeamProvisioningService {
this.aliveRunByTeam.set(run.teamName, run.runId);
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
// Force a post-ready detail refresh so Messages reload persisted lead_session
// texts from JSONL even if the last visible assistant output only reached disk.
this.teamChangeEmitter?.({
type: 'lead-message',
teamName: run.teamName,
runId: run.runId,
detail: 'lead-session-sync',
});
// Fire "Team Launched" notification
void this.fireTeamLaunchedNotification(run);

View file

@ -6,6 +6,7 @@ import { api } from '@renderer/api';
import { syncRendererTelemetry } from '@renderer/sentry';
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
@ -53,6 +54,8 @@ const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
const FINISHED_TOOL_DISPLAY_MS = 1_500;
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
const CURRENT_APP_VERSION =
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
// =============================================================================
// Store Creation
@ -1199,12 +1202,16 @@ export function initializeNotificationListeners(): () => void {
if (currentStatus === 'downloading' || currentStatus === 'downloaded') {
break;
}
const nextVersion = s.version ? normalizeVersion(s.version) : null;
if (!nextVersion || !isVersionOlder(CURRENT_APP_VERSION, nextVersion)) {
break;
}
const dismissed = useStore.getState().dismissedUpdateVersion;
useStore.setState({
updateStatus: 'available',
availableVersion: s.version ?? null,
availableVersion: nextVersion,
releaseNotes: s.releaseNotes ?? null,
showUpdateDialog: (s.version ?? null) !== dismissed,
showUpdateDialog: nextVersion !== dismissed,
});
break;
}
@ -1223,10 +1230,15 @@ export function initializeNotificationListeners(): () => void {
});
break;
case 'downloaded':
if (s.version && !isVersionOlder(CURRENT_APP_VERSION, normalizeVersion(s.version))) {
break;
}
useStore.setState({
updateStatus: 'downloaded',
downloadProgress: 100,
availableVersion: s.version ?? useStore.getState().availableVersion,
availableVersion: s.version
? normalizeVersion(s.version)
: useStore.getState().availableVersion,
});
break;
case 'error': {

View file

@ -1,5 +1,7 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare module '*.png' {
const src: string;
// eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports

View file

@ -0,0 +1,29 @@
/**
* Extract semver-like version from strings such as "v1.2.3" or "1.2.3 (beta)".
*/
export function normalizeVersion(raw: string): string {
const match = /\d{1,10}\.\d{1,10}\.\d{1,10}/.exec(raw);
return match ? match[0] : raw.trim();
}
/**
* Numeric semver comparison.
* Returns -1 if a < b, 0 if equal, 1 if a > b.
*/
export function compareVersions(a: string, b: string): number {
const aParts = normalizeVersion(a).split('.').map(Number);
const bParts = normalizeVersion(b).split('.').map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const left = aParts[i] ?? 0;
const right = bParts[i] ?? 0;
if (left < right) return -1;
if (left > right) return 1;
}
return 0;
}
export function isVersionOlder(installed: string, latest: string): boolean {
return compareVersions(installed, latest) < 0;
}

View file

@ -332,7 +332,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
});
it('captures SendMessage(to:team-lead) without rendering duplicate assistant thought text', () => {
it('keeps assistant thought text when SendMessage targets a teammate', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: true });
@ -355,15 +355,48 @@ describe('TeamProvisioningService pre-ready live messages', () => {
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].to).toBe('team-lead');
expect(live[0].text).toBe('Need clarification on #abcd1234');
expect(live).toHaveLength(2);
expect(live[0].to).toBeUndefined();
expect(live[0].text).toBe('Forwarding the clarification request now.');
expect(live[0].source).toBe('lead_process');
expect(live[1].to).toBe('team-lead');
expect(live[1].text).toBe('Need clarification on #abcd1234');
expect(live[1].source).toBe('lead_process');
// Non-user recipient → delivered to inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
it('suppresses duplicate assistant thought text when SendMessage targets user', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{ type: 'text', text: 'Task completed. Sending the summary now.' },
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'user',
content: 'Task completed successfully.',
summary: 'Done',
},
},
],
});
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].to).toBe('user');
expect(live[0].text).toBe('Task completed successfully.');
expect(live[0].source).toBe('lead_process');
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
});
it('post-ready path also uses the unified helper', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');

View file

@ -223,4 +223,61 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
})
);
});
it('emits a lead-message refresh after provisioning reaches ready', async () => {
const svc = new TeamProvisioningService();
const emitter = vi.fn();
svc.setTeamChangeEmitter(emitter);
const run = {
runId: 'run-3',
teamName: 'team-alpha',
request: {
cwd: tempRoot,
color: 'blue',
members: [{ name: 'dev', role: 'engineer' }],
},
progress: {
runId: 'run-3',
teamName: 'team-alpha',
state: 'assembling',
message: 'Assembling',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:00.000Z',
},
provisioningComplete: false,
cancelRequested: false,
processKilled: false,
stdoutBuffer: '',
stdoutLogLineBuf: '',
stdoutParserCarry: '',
stdoutParserCarryIsCompleteJson: false,
stdoutParserCarryLooksLikeClaudeJson: false,
stderrBuffer: '',
stderrLogLineBuf: '',
provisioningOutputParts: [],
onProgress: vi.fn(),
isLaunch: true,
detectedSessionId: null,
timeoutHandle: null,
fsMonitorHandle: null,
claudeLogLines: [],
activeToolCalls: new Map(),
leadActivityState: 'active',
leadContextUsage: null,
};
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
await (svc as any).handleProvisioningTurnComplete(run);
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'lead-message',
teamName: 'team-alpha',
runId: 'run-3',
detail: 'lead-session-sync',
})
);
});
});

View file

@ -257,6 +257,55 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
});
it('shows assistant text after relay capture has already settled', () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
attachAliveRun(service, teamName);
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as {
leadRelayCapture: {
leadName: string;
startedAt: string;
textParts: string[];
settled: boolean;
idleHandle: NodeJS.Timeout | null;
idleMs: number;
resolveOnce: (text: string) => void;
rejectOnce: (error: string) => void;
timeoutHandle: NodeJS.Timeout;
} | null;
};
run.leadRelayCapture = {
leadName: 'team-lead',
startedAt: new Date().toISOString(),
textParts: [],
settled: true,
idleHandle: null,
idleMs: 800,
resolveOnce: vi.fn(),
rejectOnce: vi.fn(),
timeoutHandle: setTimeout(() => undefined, 60_000),
};
try {
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
content: [{ type: 'text', text: 'Late reply after relay completion.' }],
});
const live = service.getLiveLeadProcessMessages(teamName);
expect(live).toHaveLength(1);
expect(live[0].to).toBeUndefined();
expect(live[0].text).toBe('Late reply after relay completion.');
expect(live[0].source).toBe('lead_process');
} finally {
clearTimeout(run.leadRelayCapture.timeoutHandle);
run.leadRelayCapture = null;
}
});
it('adds task-first reply guidance for task comment notifications in lead relay prompts', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';