fix(frontend): initializeStores dedup + retryAllMessages guard + as any cleanup
- index.ts: add _storesInitialized guard to prevent triple initialization - offlineStore.ts: add isRetrying mutex for retryAllMessages concurrency - PropertyPanel.tsx: replace 13x (data as any) with typed d accessor - chatStore.ts: replace window as any with Record<string, unknown> - kernel-*.ts: replace prototype as any with Record<string, unknown> - gateway-heartbeat.ts: delete dead code (9 as any, zero imports)
This commit is contained in:
@@ -87,6 +87,8 @@ function renderTypeSpecificFields(
|
|||||||
data: Partial<WorkflowNodeData>,
|
data: Partial<WorkflowNodeData>,
|
||||||
onChange: (field: string, value: unknown) => void
|
onChange: (field: string, value: unknown) => void
|
||||||
) {
|
) {
|
||||||
|
// Type-safe property accessor for union-typed node data
|
||||||
|
const d = data as Record<string, unknown>;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'input':
|
case 'input':
|
||||||
return (
|
return (
|
||||||
@@ -97,7 +99,7 @@ function renderTypeSpecificFields(
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(data as any).variableName || ''}
|
value={d.variableName || ''}
|
||||||
onChange={(e) => onChange('variableName', e.target.value)}
|
onChange={(e) => onChange('variableName', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
||||||
/>
|
/>
|
||||||
@@ -107,7 +109,7 @@ function renderTypeSpecificFields(
|
|||||||
Default Value
|
Default Value
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={(data as any).defaultValue || ''}
|
value={d.defaultValue || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(e.target.value);
|
const parsed = JSON.parse(e.target.value);
|
||||||
@@ -132,7 +134,7 @@ function renderTypeSpecificFields(
|
|||||||
Template
|
Template
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={(data as any).template || ''}
|
value={d.template || ''}
|
||||||
onChange={(e) => onChange('template', e.target.value)}
|
onChange={(e) => onChange('template', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
||||||
rows={6}
|
rows={6}
|
||||||
@@ -144,7 +146,7 @@ function renderTypeSpecificFields(
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(data as any).model || ''}
|
value={d.model || ''}
|
||||||
onChange={(e) => onChange('model', e.target.value)}
|
onChange={(e) => onChange('model', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
placeholder="e.g., gpt-4"
|
placeholder="e.g., gpt-4"
|
||||||
@@ -159,7 +161,7 @@ function renderTypeSpecificFields(
|
|||||||
min="0"
|
min="0"
|
||||||
max="2"
|
max="2"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={(data as any).temperature ?? ''}
|
value={d.temperature ?? ''}
|
||||||
onChange={(e) => onChange('temperature', parseFloat(e.target.value))}
|
onChange={(e) => onChange('temperature', parseFloat(e.target.value))}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -167,7 +169,7 @@ function renderTypeSpecificFields(
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={(data as any).jsonMode || false}
|
checked={d.jsonMode || false}
|
||||||
onChange={(e) => onChange('jsonMode', e.target.checked)}
|
onChange={(e) => onChange('jsonMode', e.target.checked)}
|
||||||
className="w-4 h-4 text-blue-600 rounded"
|
className="w-4 h-4 text-blue-600 rounded"
|
||||||
/>
|
/>
|
||||||
@@ -185,7 +187,7 @@ function renderTypeSpecificFields(
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(data as any).skillId || ''}
|
value={d.skillId || ''}
|
||||||
onChange={(e) => onChange('skillId', e.target.value)}
|
onChange={(e) => onChange('skillId', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
||||||
/>
|
/>
|
||||||
@@ -195,7 +197,7 @@ function renderTypeSpecificFields(
|
|||||||
Input Mappings (JSON)
|
Input Mappings (JSON)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={JSON.stringify((data as any).inputMappings || {}, null, 2)}
|
value={JSON.stringify(d.inputMappings || {}, null, 2)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(e.target.value);
|
const parsed = JSON.parse(e.target.value);
|
||||||
@@ -220,7 +222,7 @@ function renderTypeSpecificFields(
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(data as any).handId || ''}
|
value={d.handId || ''}
|
||||||
onChange={(e) => onChange('handId', e.target.value)}
|
onChange={(e) => onChange('handId', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
||||||
/>
|
/>
|
||||||
@@ -231,7 +233,7 @@ function renderTypeSpecificFields(
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(data as any).action || ''}
|
value={d.action || ''}
|
||||||
onChange={(e) => onChange('action', e.target.value)}
|
onChange={(e) => onChange('action', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
/>
|
/>
|
||||||
@@ -251,9 +253,9 @@ function renderTypeSpecificFields(
|
|||||||
<label key={format} className="flex items-center gap-2">
|
<label key={format} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={((data as any).formats || []).includes(format)}
|
checked={(d.formats || []).includes(format)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const formats = (data as any).formats || [];
|
const formats = d.formats || [];
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
onChange('formats', [...formats, format]);
|
onChange('formats', [...formats, format]);
|
||||||
} else {
|
} else {
|
||||||
@@ -273,7 +275,7 @@ function renderTypeSpecificFields(
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={(data as any).outputDir || ''}
|
value={d.outputDir || ''}
|
||||||
onChange={(e) => onChange('outputDir', e.target.value)}
|
onChange={(e) => onChange('outputDir', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
||||||
placeholder="./output"
|
placeholder="./output"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const logger = createLogger('GatewayApi');
|
|||||||
// === Install all API methods onto GatewayClient prototype ===
|
// === Install all API methods onto GatewayClient prototype ===
|
||||||
|
|
||||||
export function installApiMethods(ClientClass: { prototype: GatewayClient }): void {
|
export function installApiMethods(ClientClass: { prototype: GatewayClient }): void {
|
||||||
const proto = ClientClass.prototype as any;
|
const proto = ClientClass.prototype as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
// ─── Health / Status ───
|
// ─── Health / Status ───
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
/**
|
|
||||||
* gateway-heartbeat.ts - Gateway Heartbeat Methods
|
|
||||||
*
|
|
||||||
* Extracted from gateway-client.ts for modularity.
|
|
||||||
* Installs heartbeat methods onto GatewayClient.prototype via mixin pattern.
|
|
||||||
*
|
|
||||||
* Heartbeat constants are defined here as module-level values
|
|
||||||
* to avoid static field coupling with the main class.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { GatewayClient } from './gateway-client';
|
|
||||||
|
|
||||||
// === Heartbeat Constants ===
|
|
||||||
|
|
||||||
/** Interval between heartbeat pings (30 seconds) */
|
|
||||||
export const HEARTBEAT_INTERVAL = 30000;
|
|
||||||
|
|
||||||
/** Timeout for waiting for pong response (10 seconds) */
|
|
||||||
export const HEARTBEAT_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
/** Maximum missed heartbeats before reconnecting */
|
|
||||||
export const MAX_MISSED_HEARTBEATS = 3;
|
|
||||||
|
|
||||||
// === Mixin Installer ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install heartbeat methods onto GatewayClient.prototype.
|
|
||||||
*
|
|
||||||
* These methods access instance properties:
|
|
||||||
* - this.ws: WebSocket | null
|
|
||||||
* - this.heartbeatInterval: number | null
|
|
||||||
* - this.heartbeatTimeout: number | null
|
|
||||||
* - this.missedHeartbeats: number
|
|
||||||
* - this.log(level, message): void
|
|
||||||
* - this.stopHeartbeat(): void
|
|
||||||
*/
|
|
||||||
export function installHeartbeatMethods(ClientClass: { prototype: GatewayClient }): void {
|
|
||||||
const proto = ClientClass.prototype as any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start heartbeat to keep connection alive.
|
|
||||||
* Called after successful connection.
|
|
||||||
*/
|
|
||||||
proto.startHeartbeat = function (this: GatewayClient): void {
|
|
||||||
(this as any).stopHeartbeat();
|
|
||||||
(this as any).missedHeartbeats = 0;
|
|
||||||
|
|
||||||
(this as any).heartbeatInterval = window.setInterval(() => {
|
|
||||||
(this as any).sendHeartbeat();
|
|
||||||
}, HEARTBEAT_INTERVAL);
|
|
||||||
|
|
||||||
(this as any).log('debug', 'Heartbeat started');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop heartbeat.
|
|
||||||
* Called on cleanup or disconnect.
|
|
||||||
*/
|
|
||||||
proto.stopHeartbeat = function (this: GatewayClient): void {
|
|
||||||
const self = this as any;
|
|
||||||
if (self.heartbeatInterval) {
|
|
||||||
clearInterval(self.heartbeatInterval);
|
|
||||||
self.heartbeatInterval = null;
|
|
||||||
}
|
|
||||||
if (self.heartbeatTimeout) {
|
|
||||||
clearTimeout(self.heartbeatTimeout);
|
|
||||||
self.heartbeatTimeout = null;
|
|
||||||
}
|
|
||||||
self.log('debug', 'Heartbeat stopped');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a ping heartbeat to the server.
|
|
||||||
*/
|
|
||||||
proto.sendHeartbeat = function (this: GatewayClient): void {
|
|
||||||
const self = this as any;
|
|
||||||
if (self.ws?.readyState !== WebSocket.OPEN) {
|
|
||||||
self.log('debug', 'Skipping heartbeat - WebSocket not open');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.missedHeartbeats++;
|
|
||||||
if (self.missedHeartbeats > MAX_MISSED_HEARTBEATS) {
|
|
||||||
self.log('warn', `Max missed heartbeats (${MAX_MISSED_HEARTBEATS}), reconnecting`);
|
|
||||||
self.stopHeartbeat();
|
|
||||||
self.ws.close(4000, 'Heartbeat timeout');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send ping frame
|
|
||||||
try {
|
|
||||||
self.ws.send(JSON.stringify({ type: 'ping' }));
|
|
||||||
self.log('debug', `Ping sent (missed: ${self.missedHeartbeats})`);
|
|
||||||
|
|
||||||
// Set timeout for pong
|
|
||||||
self.heartbeatTimeout = window.setTimeout(() => {
|
|
||||||
self.log('warn', 'Heartbeat pong timeout');
|
|
||||||
// Don't reconnect immediately, let the next heartbeat check
|
|
||||||
}, HEARTBEAT_TIMEOUT);
|
|
||||||
} catch (error) {
|
|
||||||
self.log('error', `Failed to send heartbeat: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle pong response from server.
|
|
||||||
*/
|
|
||||||
proto.handlePong = function (this: GatewayClient): void {
|
|
||||||
const self = this as any;
|
|
||||||
self.missedHeartbeats = 0;
|
|
||||||
if (self.heartbeatTimeout) {
|
|
||||||
clearTimeout(self.heartbeatTimeout);
|
|
||||||
self.heartbeatTimeout = null;
|
|
||||||
}
|
|
||||||
self.log('debug', 'Pong received, heartbeat reset');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import type { KernelClient } from './kernel-client';
|
import type { KernelClient } from './kernel-client';
|
||||||
|
|
||||||
export function installA2aMethods(ClientClass: { prototype: KernelClient }): void {
|
export function installA2aMethods(ClientClass: { prototype: KernelClient }): void {
|
||||||
const proto = ClientClass.prototype as any;
|
const proto = ClientClass.prototype as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
// ─── A2A (Agent-to-Agent) API ───
|
// ─── A2A (Agent-to-Agent) API ───
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { KernelClient } from './kernel-client';
|
|||||||
import type { AgentInfo, CreateAgentRequest, CreateAgentResponse } from './kernel-types';
|
import type { AgentInfo, CreateAgentRequest, CreateAgentResponse } from './kernel-types';
|
||||||
|
|
||||||
export function installAgentMethods(ClientClass: { prototype: KernelClient }): void {
|
export function installAgentMethods(ClientClass: { prototype: KernelClient }): void {
|
||||||
const proto = ClientClass.prototype as any;
|
const proto = ClientClass.prototype as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
// ─── Agent Management ───
|
// ─── Agent Management ───
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { ChatResponse, StreamCallbacks, StreamChunkPayload } from './kernel
|
|||||||
const log = createLogger('KernelClient');
|
const log = createLogger('KernelClient');
|
||||||
|
|
||||||
export function installChatMethods(ClientClass: { prototype: KernelClient }): void {
|
export function installChatMethods(ClientClass: { prototype: KernelClient }): void {
|
||||||
const proto = ClientClass.prototype as any;
|
const proto = ClientClass.prototype as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message and get a response
|
* Send a message and get a response
|
||||||
@@ -227,13 +227,13 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
|
|||||||
* Set default agent ID
|
* Set default agent ID
|
||||||
*/
|
*/
|
||||||
proto.setDefaultAgentId = function (this: KernelClient, agentId: string): void {
|
proto.setDefaultAgentId = function (this: KernelClient, agentId: string): void {
|
||||||
(this as any).defaultAgentId = agentId;
|
this.defaultAgentId = agentId;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default agent ID
|
* Get default agent ID
|
||||||
*/
|
*/
|
||||||
proto.getDefaultAgentId = function (this: KernelClient): string {
|
proto.getDefaultAgentId = function (this: KernelClient): string {
|
||||||
return (this as any).defaultAgentId || '';
|
return this.defaultAgentId || '';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface HandExecutionCompletePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function installHandMethods(ClientClass: { prototype: KernelClient }): void {
|
export function installHandMethods(ClientClass: { prototype: KernelClient }): void {
|
||||||
const proto = ClientClass.prototype as any;
|
const proto = ClientClass.prototype as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
// ─── Hands API ───
|
// ─── Hands API ───
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type SkillItem = {
|
|||||||
type SkillListResult = { skills: SkillItem[] };
|
type SkillListResult = { skills: SkillItem[] };
|
||||||
|
|
||||||
export function installSkillMethods(ClientClass: { prototype: KernelClient }): void {
|
export function installSkillMethods(ClientClass: { prototype: KernelClient }): void {
|
||||||
const proto = ClientClass.prototype as any;
|
const proto = ClientClass.prototype as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
// ─── Skills API ───
|
// ─── Skills API ───
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type TriggerTypeSpec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function installTriggerMethods(ClientClass: { prototype: KernelClient }): void {
|
export function installTriggerMethods(ClientClass: { prototype: KernelClient }): void {
|
||||||
const proto = ClientClass.prototype as any;
|
const proto = ClientClass.prototype as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
// ─── Triggers API ───
|
// ─── Triggers API ───
|
||||||
|
|
||||||
|
|||||||
@@ -354,8 +354,10 @@ if (import.meta.hot) {
|
|||||||
|
|
||||||
// Dev-only: Expose stores to window for E2E testing
|
// Dev-only: Expose stores to window for E2E testing
|
||||||
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
(window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {};
|
const w = window as Record<string, unknown>;
|
||||||
(window as any).__ZCLAW_STORES__.chat = useChatStore;
|
w.__ZCLAW_STORES__ = (w.__ZCLAW_STORES__ as Record<string, unknown>) || {};
|
||||||
(window as any).__ZCLAW_STORES__.message = useMessageStore;
|
const stores = w.__ZCLAW_STORES__ as Record<string, unknown>;
|
||||||
(window as any).__ZCLAW_STORES__.stream = useStreamStore;
|
stores.chat = useChatStore;
|
||||||
|
stores.message = useMessageStore;
|
||||||
|
stores.stream = useStreamStore;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,16 @@ import { setConfigStoreClient } from './configStore';
|
|||||||
import { setSecurityStoreClient } from './securityStore';
|
import { setSecurityStoreClient } from './securityStore';
|
||||||
import { setSessionStoreClient } from './sessionStore';
|
import { setSessionStoreClient } from './sessionStore';
|
||||||
|
|
||||||
|
let _storesInitialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all stores with the shared client.
|
* Initialize all stores with the shared client.
|
||||||
* Called once when the application mounts.
|
* Called once when the application mounts.
|
||||||
|
* Guard ensures it only runs once even if called from multiple connection paths.
|
||||||
*/
|
*/
|
||||||
export function initializeStores(): void {
|
export function initializeStores(): void {
|
||||||
|
if (_storesInitialized) return;
|
||||||
|
_storesInitialized = true;
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
|
|
||||||
// Inject client into all stores
|
// Inject client into all stores
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function generateMessageId(): string {
|
|||||||
|
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let isRetrying = false;
|
||||||
|
|
||||||
export const useOfflineStore = create<OfflineStore>()(
|
export const useOfflineStore = create<OfflineStore>()(
|
||||||
persist(
|
persist(
|
||||||
@@ -186,6 +187,12 @@ export const useOfflineStore = create<OfflineStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
retryAllMessages: async () => {
|
retryAllMessages: async () => {
|
||||||
|
if (isRetrying) {
|
||||||
|
log.debug('retryAllMessages already in progress, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isRetrying = true;
|
||||||
|
try {
|
||||||
const state = get();
|
const state = get();
|
||||||
const pending = state.queuedMessages.filter(
|
const pending = state.queuedMessages.filter(
|
||||||
(m) => m.status === 'pending' || m.status === 'failed'
|
(m) => m.status === 'pending' || m.status === 'failed'
|
||||||
@@ -230,6 +237,9 @@ export const useOfflineStore = create<OfflineStore>()(
|
|||||||
log.warn(`Message ${msg.id} failed:`, errorMessage);
|
log.warn(`Message ${msg.id} failed:`, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
isRetrying = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// === Reconnection ===
|
// === Reconnection ===
|
||||||
|
|||||||
Reference in New Issue
Block a user