From f08885d58f6bab51f80bf48b775e3b59f8827f2a Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 15:24:23 +0300 Subject: [PATCH] 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. --- .github/workflows/release.yml | 88 ++++++++++++++++++- .../services/infrastructure/UpdaterService.ts | 34 ++++++- .../services/team/TeamProvisioningService.ts | 76 +++++++++++----- src/renderer/store/index.ts | 18 +++- src/renderer/vite-env.d.ts | 2 + src/shared/utils/version.ts | 29 ++++++ ...eamProvisioningServiceLiveMessages.test.ts | 41 ++++++++- .../TeamProvisioningServicePrepare.test.ts | 57 ++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 49 +++++++++++ 9 files changed, 362 insertions(+), 32 deletions(-) create mode 100644 src/shared/utils/version.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5833d37..4766dbae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 < latest-linux.yml < latest-mac.yml < { export class UpdaterService { private mainWindow: BrowserWindow | null = null; private periodicTimer: ReturnType | 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 { + 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', diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 75fadb6c..be69f892 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1695,6 +1695,28 @@ export class TeamProvisioningService { return Array.isArray(innerContent) ? (innerContent as Record[]) : []; } + private hasCapturedVisibleMessageToUser(content: Record[]): 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; + 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).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); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 52602af4..399ad1b1 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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': { diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index 827db198..132b9c26 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -1,5 +1,7 @@ /// +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 diff --git a/src/shared/utils/version.ts b/src/shared/utils/version.ts new file mode 100644 index 00000000..f4fa2ca1 --- /dev/null +++ b/src/shared/utils/version.ts @@ -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; +} diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 8a5f930e..7833cf25 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -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'); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index c8bbfa26..dfc8ca1e 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -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', + }) + ); + }); }); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 73e4318a..df618c77 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -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 }).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';