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:
parent
e4c9a100f3
commit
f08885d58f
9 changed files with 362 additions and 32 deletions
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
|
|
@ -143,7 +143,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
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
|
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
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
|
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -285,7 +285,7 @@ jobs:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
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
|
[ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -326,3 +326,85 @@ jobs:
|
||||||
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber
|
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber
|
||||||
rm -f "$STABLE_NAME"
|
rm -f "$STABLE_NAME"
|
||||||
done
|
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
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@
|
||||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||||
import electronUpdater from 'electron-updater';
|
import electronUpdater from 'electron-updater';
|
||||||
|
|
||||||
const { autoUpdater } = electronUpdater;
|
const { autoUpdater } = electronUpdater;
|
||||||
|
|
||||||
import { net } from 'electron';
|
import { app, net } from 'electron';
|
||||||
|
|
||||||
import type { UpdaterStatus } from '@shared/types';
|
import type { UpdaterStatus } from '@shared/types';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
@ -64,6 +65,7 @@ async function assetExists(url: string): Promise<boolean> {
|
||||||
export class UpdaterService {
|
export class UpdaterService {
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
private periodicTimer: ReturnType<typeof setInterval> | null = null;
|
private periodicTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private downloadedVersion: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
autoUpdater.autoDownload = false;
|
autoUpdater.autoDownload = false;
|
||||||
|
|
@ -109,6 +111,17 @@ export class UpdaterService {
|
||||||
* isForceRunAfter=true launches the app after install. Other platforms ignore these.
|
* isForceRunAfter=true launches the app after install. Other platforms ignore these.
|
||||||
*/
|
*/
|
||||||
quitAndInstall(): void {
|
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);
|
autoUpdater.quitAndInstall(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,6 +150,10 @@ export class UpdaterService {
|
||||||
safeSendToRenderer(this.mainWindow, 'updater:status', status);
|
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.
|
* 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
|
* If CI hasn't finished uploading the artifact for this OS yet, suppress the
|
||||||
|
|
@ -146,6 +163,13 @@ export class UpdaterService {
|
||||||
version: string;
|
version: string;
|
||||||
releaseNotes?: string | unknown;
|
releaseNotes?: string | unknown;
|
||||||
}): Promise<void> {
|
}): 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);
|
const url = getExpectedAssetUrl(info.version);
|
||||||
if (url) {
|
if (url) {
|
||||||
const exists = await assetExists(url);
|
const exists = await assetExists(url);
|
||||||
|
|
@ -192,6 +216,14 @@ export class UpdaterService {
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info) => {
|
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);
|
logger.info('Update downloaded:', info.version);
|
||||||
this.sendStatus({
|
this.sendStatus({
|
||||||
type: 'downloaded',
|
type: 'downloaded',
|
||||||
|
|
|
||||||
|
|
@ -1695,6 +1695,28 @@ export class TeamProvisioningService {
|
||||||
return Array.isArray(innerContent) ? (innerContent as Record<string, unknown>[]) : [];
|
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(
|
private async matchCrossTeamLeadInboxMessages(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
|
|
@ -5142,14 +5164,7 @@ export class TeamProvisioningService {
|
||||||
if (msg.type === 'assistant') {
|
if (msg.type === 'assistant') {
|
||||||
const content = this.extractStreamContentBlocks(msg);
|
const content = this.extractStreamContentBlocks(msg);
|
||||||
|
|
||||||
const hasCapturedSendMessage = content.some((part) => {
|
const hasCapturedVisibleMessageToUser = this.hasCapturedVisibleMessageToUser(content);
|
||||||
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 textParts = content
|
const textParts = content
|
||||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||||
|
|
@ -5170,26 +5185,27 @@ export class TeamProvisioningService {
|
||||||
run.provisioningOutputParts.push(text);
|
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;
|
const capture = run.leadRelayCapture;
|
||||||
if (!capture.settled) {
|
capture.textParts.push(text);
|
||||||
capture.textParts.push(text);
|
if (capture.idleHandle) {
|
||||||
if (capture.idleHandle) {
|
clearTimeout(capture.idleHandle);
|
||||||
clearTimeout(capture.idleHandle);
|
|
||||||
}
|
|
||||||
capture.idleHandle = setTimeout(() => {
|
|
||||||
const combined = capture.textParts.join('\n').trim();
|
|
||||||
capture.resolveOnce(combined);
|
|
||||||
}, capture.idleMs);
|
|
||||||
}
|
}
|
||||||
|
capture.idleHandle = setTimeout(() => {
|
||||||
|
const combined = capture.textParts.join('\n').trim();
|
||||||
|
capture.resolveOnce(combined);
|
||||||
|
}, capture.idleMs);
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Push each assistant text block as a separate live message (per-message pattern).
|
// 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.
|
// captureSendMessages() handles the visible outbound message separately.
|
||||||
if (
|
if (
|
||||||
!run.silentUserDmForward &&
|
!run.silentUserDmForward &&
|
||||||
!run.suppressPostCompactReminderOutput &&
|
!run.suppressPostCompactReminderOutput &&
|
||||||
!hasCapturedSendMessage
|
!hasCapturedVisibleMessageToUser
|
||||||
) {
|
) {
|
||||||
const cleanText = stripAgentBlocks(text).trim();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length > 0) {
|
if (cleanText.length > 0) {
|
||||||
|
|
@ -5203,7 +5219,7 @@ export class TeamProvisioningService {
|
||||||
} else {
|
} else {
|
||||||
// Pre-ready: keep showing provisioning narration in the banner, but also mirror it
|
// 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.
|
// 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();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length > 0) {
|
if (cleanText.length > 0) {
|
||||||
this.pushLiveLeadTextMessage(
|
this.pushLiveLeadTextMessage(
|
||||||
|
|
@ -6530,6 +6546,15 @@ export class TeamProvisioningService {
|
||||||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||||
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
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
|
// Fire "Team Launched" notification
|
||||||
void this.fireTeamLaunchedNotification(run);
|
void this.fireTeamLaunchedNotification(run);
|
||||||
|
|
||||||
|
|
@ -6629,6 +6654,15 @@ export class TeamProvisioningService {
|
||||||
this.aliveRunByTeam.set(run.teamName, run.runId);
|
this.aliveRunByTeam.set(run.teamName, run.runId);
|
||||||
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
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
|
// Fire "Team Launched" notification
|
||||||
void this.fireTeamLaunchedNotification(run);
|
void this.fireTeamLaunchedNotification(run);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { api } from '@renderer/api';
|
||||||
import { syncRendererTelemetry } from '@renderer/sentry';
|
import { syncRendererTelemetry } from '@renderer/sentry';
|
||||||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||||
import {
|
import {
|
||||||
buildTaskChangePresenceKey,
|
buildTaskChangePresenceKey,
|
||||||
buildTaskChangeRequestOptions,
|
buildTaskChangeRequestOptions,
|
||||||
|
|
@ -53,6 +54,8 @@ const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false;
|
||||||
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
|
const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000;
|
||||||
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
const FINISHED_TOOL_DISPLAY_MS = 1_500;
|
||||||
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
|
const MAX_TOOL_HISTORY_PER_MEMBER = 6;
|
||||||
|
const CURRENT_APP_VERSION =
|
||||||
|
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Store Creation
|
// Store Creation
|
||||||
|
|
@ -1199,12 +1202,16 @@ export function initializeNotificationListeners(): () => void {
|
||||||
if (currentStatus === 'downloading' || currentStatus === 'downloaded') {
|
if (currentStatus === 'downloading' || currentStatus === 'downloaded') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
const nextVersion = s.version ? normalizeVersion(s.version) : null;
|
||||||
|
if (!nextVersion || !isVersionOlder(CURRENT_APP_VERSION, nextVersion)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
const dismissed = useStore.getState().dismissedUpdateVersion;
|
const dismissed = useStore.getState().dismissedUpdateVersion;
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
updateStatus: 'available',
|
updateStatus: 'available',
|
||||||
availableVersion: s.version ?? null,
|
availableVersion: nextVersion,
|
||||||
releaseNotes: s.releaseNotes ?? null,
|
releaseNotes: s.releaseNotes ?? null,
|
||||||
showUpdateDialog: (s.version ?? null) !== dismissed,
|
showUpdateDialog: nextVersion !== dismissed,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1223,10 +1230,15 @@ export function initializeNotificationListeners(): () => void {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'downloaded':
|
case 'downloaded':
|
||||||
|
if (s.version && !isVersionOlder(CURRENT_APP_VERSION, normalizeVersion(s.version))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
updateStatus: 'downloaded',
|
updateStatus: 'downloaded',
|
||||||
downloadProgress: 100,
|
downloadProgress: 100,
|
||||||
availableVersion: s.version ?? useStore.getState().availableVersion,
|
availableVersion: s.version
|
||||||
|
? normalizeVersion(s.version)
|
||||||
|
: useStore.getState().availableVersion,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'error': {
|
case 'error': {
|
||||||
|
|
|
||||||
2
src/renderer/vite-env.d.ts
vendored
2
src/renderer/vite-env.d.ts
vendored
|
|
@ -1,5 +1,7 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
declare module '*.png' {
|
declare module '*.png' {
|
||||||
const src: string;
|
const src: string;
|
||||||
// eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports
|
// eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports
|
||||||
|
|
|
||||||
29
src/shared/utils/version.ts
Normal file
29
src/shared/utils/version.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -332,7 +332,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
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();
|
const service = new TeamProvisioningService();
|
||||||
seedConfig('my-team');
|
seedConfig('my-team');
|
||||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||||
|
|
@ -355,15 +355,48 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const live = service.getLiveLeadProcessMessages('my-team');
|
const live = service.getLiveLeadProcessMessages('my-team');
|
||||||
expect(live).toHaveLength(1);
|
expect(live).toHaveLength(2);
|
||||||
expect(live[0].to).toBe('team-lead');
|
expect(live[0].to).toBeUndefined();
|
||||||
expect(live[0].text).toBe('Need clarification on #abcd1234');
|
expect(live[0].text).toBe('Forwarding the clarification request now.');
|
||||||
expect(live[0].source).toBe('lead_process');
|
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
|
// Non-user recipient → delivered to inbox, not sentMessages
|
||||||
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
|
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
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', () => {
|
it('post-ready path also uses the unified helper', () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
seedConfig('my-team');
|
seedConfig('my-team');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,55 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
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 () => {
|
it('adds task-first reply guidance for task comment notifications in lead relay prompts', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue