@@ -349,7 +361,6 @@ const {
selectedNode
} = storeToRefs(instance)
const {
- processGraph,
loadSavedFile,
setIsRunning,
pushLine,
@@ -372,8 +383,10 @@ const { update } = filesStore
const authStore = useAuth()
const { isLoggedIn } = storeToRefs(authStore)
-const appSettings = useAppSettings()
-const { settings: settingsRef } = storeToRefs(appSettings)
+// Build history access
+const hasBuildHistoryAccess = computed(() => {
+ return authStore.hasBenefit('cloud-save')
+})
const quickLogs = ref([])
@@ -450,99 +463,71 @@ const run = async () => {
const collectedTmpPaths: string[] = []
try {
- await processGraph({
- graph: klona(nodes.value),
- definitions: pluginDefinitions.value,
- variables: variables.value,
- context: {},
- steps: {},
- onNodeEnter: (node) => {
- setActiveNode(node)
- lastActiveNode.value = node
- },
- onNodeExit: () => {
- setActiveNode(undefined)
+ const result = await api.execute(
+ 'graph:execute',
+ {
+ graph: klona(nodes.value),
+ pipelineId: id.value,
+ variables: variables.value,
+ projectName: name.value,
+ projectPath:
+ currentFilePointer.value.type === 'external' ? currentFilePointer.value.path : undefined
},
- onExecuteItem: async (node, params, steps) => {
- posthog.capture(`node_executed`, {
- origin_node_id: node.origin.nodeId,
- origin_plugin_id: node.origin.pluginId
- })
-
- nodeStatuses.value[node.uid] = 'running'
- /* if (node.type === 'condition') {
- return api.execute('condition:execute', {
- nodeId: node.origin.nodeId,
- pluginId: node.origin.pluginId,
- params,
- steps
- })
- } else */
- currentLogAccordion.value = node.uid
- if (node.type === 'action') {
- const result = await api.execute(
- 'action:execute',
- {
- nodeId: node.origin.nodeId,
- pluginId: node.origin.pluginId,
- params,
- steps
- },
- async (event, data) => {
- // console.log('event', event)
- // console.log('data', data)
- if (data.type === 'log') {
- const lines = data.data.message.join(' ')
-
- const splittedInnerLines = lines.split('\n')
-
- for (const l of splittedInnerLines
- .map((x) => x.trim())
- .filter((x) => !!x)
- .filter((x) => x !== '')) {
- let content = ''
-
- if (hasAnsi(l)) {
- content += fancyAnsi.toHtml(l)
- } else {
- content += l
- }
-
- pushLine(
- node.uid,
- [format(data.data.time, 'dd/MM/yyyy - hh:mm:ss'), content].join(' ')
- )
- }
+ async (data) => {
+ console.log('data', data)
+ if (data.type === 'node-enter') {
+ const node = nodes.value.find((n) => n.uid === data.data.nodeUid)
+ if (node) {
+ setActiveNode(node)
+ lastActiveNode.value = node
+ nodeStatuses.value[node.uid] = 'running'
+ currentLogAccordion.value = node.uid
+ }
+ } else if (data.type === 'node-exit') {
+ const node = nodes.value.find((n) => n.uid === data.data.nodeUid)
+ if (node) {
+ setActiveNode(undefined)
+ nodeStatuses.value[node.uid] = 'done'
+ }
+ } else if (data.type === 'node-log') {
+ const { nodeUid, logData } = data.data
+ if (logData.type === 'log') {
+ console.log('logData', logData)
+ const lines = logData.data.message.join(' ')
+
+ const splittedInnerLines = lines.split('\n')
+
+ for (const l of splittedInnerLines
+ .map((x) => x.trim())
+ .filter((x) => !!x)
+ .filter((x) => x !== '')) {
+ let content = ''
+
+ if (hasAnsi(l)) {
+ content += fancyAnsi.toHtml(l)
+ } else {
+ content += l
}
- }
- )
- posthog.capture(`node_sucess`, {
- origin_node_id: lastActiveNode.value.origin.nodeId,
- origin_plugin_id: lastActiveNode.value.origin.pluginId
- })
- nodeStatuses.value[node.uid] = 'done'
- console.log('result', result)
-
- if (result.type === 'success') {
- collectedTmpPaths.push(result.result.tmp)
+ pushLine(
+ nodeUid,
+ [format(logData.data.time, 'dd/MM/yyyy - hh:mm:ss'), content].join(' ')
+ )
+ }
}
-
- return result
- } else {
- throw new Error('Unhandled type ' + node.type)
}
}
- })
+ )
+
+ if (result.type === 'success') {
+ posthog.capture(`node_sucess`, {
+ origin_node_id: lastActiveNode.value.origin.nodeId,
+ origin_plugin_id: lastActiveNode.value.origin.pluginId
+ })
- // Clean up temporary folders on success if setting is enabled
- if (settingsRef.value.clearTemporaryFoldersOnPipelineEnd) {
- for (const path of collectedTmpPaths) {
- await api.execute('fs:rm', {
- path,
- recursive: true,
- force: false
- })
+ // Mark all nodes as done since execution completed successfully
+ for (const node of nodes.value) {
+ nodeStatuses.value[node.uid] = 'done'
}
}
@@ -554,12 +539,15 @@ const run = async () => {
})
posthog.capture('run_succeed')
} catch (e) {
- nodeStatuses.value[lastActiveNode.value.uid] = 'error'
+ // Find the last active node and mark it as error
+ if (lastActiveNode.value) {
+ nodeStatuses.value[lastActiveNode.value.uid] = 'error'
- posthog.capture(`node_errored`, {
- origin_node_id: lastActiveNode.value.origin.nodeId,
- origin_plugin_id: lastActiveNode.value.origin.pluginId
- })
+ posthog.capture(`node_errored`, {
+ origin_node_id: lastActiveNode.value.origin.nodeId,
+ origin_plugin_id: lastActiveNode.value.origin.pluginId
+ })
+ }
console.error('error while executing process', e)
if (e instanceof Error) {
@@ -594,6 +582,19 @@ const onCloseRequest = async () => {
})
}
+const navigateToBuildHistory = async () => {
+ if (!hasBuildHistoryAccess.value) {
+ return
+ }
+
+ await router.push({
+ name: 'BuildHistory',
+ params: {
+ pipelineId: id.value
+ }
+ })
+}
+
const saveLocal = async (path: string) => {
const result: SavedFile = {
version: '3.0.0',
diff --git a/src/renderer/pages/index.vue b/src/renderer/pages/index.vue
index 13b90e82..b5dd1b5a 100644
--- a/src/renderer/pages/index.vue
+++ b/src/renderer/pages/index.vue
@@ -16,7 +16,7 @@
data-key="id"
class="w-full h-full"
:scrollable="true"
- scrollHeight="flex"
+ scroll-height="flex"
>
@@ -53,14 +53,24 @@
{{ data.path }}
-->
-
+
+
@@ -68,15 +78,15 @@
v-if="!data.noDeleteBtn"
size="small"
severity="danger"
- @click.stop="deleteProject(data.id)"
class="p-button-outlined"
+ @click.stop="deleteProject(data.id)"
>
@@ -155,7 +165,7 @@
-
+
{{ value.label }}
@@ -434,6 +444,11 @@ type Item = {
const { hasBenefit } = useAuth()
+// Build history access
+const hasBuildHistoryAccess = computed(() => {
+ return hasBenefit('cloud-save')
+})
+
const newProjectType = ref()
const newProjectTypes = computed
- (() => {
return [
@@ -557,6 +572,23 @@ const duplicateProject = async (file: SavedFile) => {
isNewProjectModalVisible.value = true
}
+const viewProjectBuildHistory = async (file: EnhancedFile) => {
+ if (!hasBuildHistoryAccess.value) {
+ return
+ }
+
+ // Use project ID for navigation to general build history
+ // For external files, use the file path as project identifier
+ const pipelineId = file.type === 'external' ? file.path : file.id
+
+ await router.push({
+ name: 'BuildHistory',
+ params: {
+ pipelineId: pipelineId
+ }
+ })
+}
+
const isNewProjectModalVisible = ref(false)
diff --git a/src/renderer/router/router.ts b/src/renderer/router/router.ts
index af75e143..91ca7180 100644
--- a/src/renderer/router/router.ts
+++ b/src/renderer/router/router.ts
@@ -49,6 +49,14 @@ const routes: RouterOptions['routes'] = [
meta: {
title: t('headers.team')
}
+ },
+ {
+ path: '/build-history/:pipelineId',
+ name: 'BuildHistory',
+ component: () => import('../pages/BuildHistoryPage.vue'),
+ meta: {
+ title: 'Build History - Scenario'
+ }
}
]
diff --git a/src/renderer/store/auth.ts b/src/renderer/store/auth.ts
index 52477819..0a5af73f 100644
--- a/src/renderer/store/auth.ts
+++ b/src/renderer/store/auth.ts
@@ -1,11 +1,12 @@
import { useLogger } from '@@/logger' // Assuming path is correct
import { supabase } from '@@/supabase' // Assuming path is correct
-import { User, UserResponse } from '@supabase/supabase-js'
+import { AuthChangeEvent, Session, User, UserResponse } from '@supabase/supabase-js'
import { defineStore } from 'pinia'
import { computed, readonly, Ref, ref, shallowRef } from 'vue'
import posthog from 'posthog-js'
import { email } from 'valibot'
import { Subscription } from '@polar-sh/sdk/dist/commonjs/models/components/subscription'
+import { createEventHook } from '@vueuse/core'
// Define a more comprehensive AuthStateType
export type AuthStateType =
@@ -28,6 +29,9 @@ export const useAuth = defineStore('auth', () => {
const authModalTitle = ref()
const authModalSubTitle = ref()
+ const onAuthChanged = createEventHook<{ event: AuthChangeEvent; session: Session }>()
+ const onSubscriptionChanged = createEventHook<{ subscriptions: Subscription[] }>()
+
const displayAuthModal = (title?: string, subtitle?: string) => {
isAuthModalVisible.value = true
authModalTitle.value = title
@@ -56,7 +60,7 @@ export const useAuth = defineStore('auth', () => {
try {
const result = await supabase.functions.invoke('polar-user-plan')
if (result.data) {
- console.log('result', result)
+ console.log('Subscription result', result)
subscriptions.value = result.data.subscriptions
}
} catch (error) {
@@ -69,6 +73,8 @@ export const useAuth = defineStore('auth', () => {
console.warn('User is anonymous, skipping subscription fetch.')
}
isLoadingSubscriptions.value = false
+ console.log('onSubscriptionChanged.trigger')
+ onSubscriptionChanged.trigger({ subscriptions: subscriptions.value })
}
supabase.auth.onAuthStateChange((event, session) => {
@@ -150,6 +156,8 @@ export const useAuth = defineStore('auth', () => {
// No signInAnonymously() here. Similar to SIGNED_OUT, handle anonymous sign-in outside if needed.
break
}
+
+ onAuthChanged.trigger({ event, session })
})
// Explicitly initialize the auth state when the store is created
@@ -283,7 +291,8 @@ export const useAuth = defineStore('auth', () => {
const isLoggedIn = computed(() => authState.value === 'SIGNED_IN')
const benefits = {
- 'cloud-save': '16955d3e-3e0f-4574-9093-87a32edf237c'
+ 'cloud-save': '16955d3e-3e0f-4574-9093-87a32edf237c',
+ 'build-history': 'b77e9800-8302-4581-8df3-6f1b979acef5'
}
const hasBenefit = (benefit: keyof typeof benefits) => {
@@ -292,6 +301,9 @@ export const useAuth = defineStore('auth', () => {
)
}
+ const hasBuildHistoryBenefit = computed(() => hasBenefit('build-history'))
+ const hasCloudSaveBenefit = computed(() => hasBenefit('cloud-save'))
+
const isLoadingSubscriptions = ref(true)
return {
@@ -314,6 +326,12 @@ export const useAuth = defineStore('auth', () => {
hideAuthModal,
isAuthModalVisible,
authModalTitle,
- authModalSubTitle
+ authModalSubTitle,
+
+ hasBuildHistoryBenefit,
+ hasCloudSaveBenefit,
+
+ onAuthChanged: onAuthChanged.on,
+ onSubscriptionChanged: onSubscriptionChanged.on
}
})
diff --git a/src/renderer/store/build-history.ts b/src/renderer/store/build-history.ts
new file mode 100644
index 00000000..4c41b974
--- /dev/null
+++ b/src/renderer/store/build-history.ts
@@ -0,0 +1,369 @@
+import { defineStore, storeToRefs } from 'pinia'
+import { ref, computed, readonly } from 'vue'
+import { useLogger } from '@@/logger'
+import { useAuth } from './auth'
+import { useAPI } from '@renderer/composables/api'
+import type {
+ BuildHistoryEntry,
+ BuildHistoryQuery,
+ BuildHistoryResponse,
+ SubscriptionError
+} from '@@/build-history'
+import { isSubscriptionError, SubscriptionRequiredError } from '@@/subscription-errors'
+
+export const useBuildHistory = defineStore('build-history', () => {
+ const api = useAPI()
+ const logger = useLogger()
+ const authStore = useAuth()
+
+ const {} = authStore
+ const { hasBuildHistoryBenefit } = storeToRefs(authStore)
+
+ const isRefreshingHistory = ref(false)
+
+ // IPC API functions
+ const buildHistoryAPI = {
+ async save(entry: BuildHistoryEntry): Promise {
+ const result = await api.execute('build-history:save', {
+ entry
+ })
+ if (result.type === 'error') {
+ throw new Error(result.ipcError || 'Failed to save build history entry')
+ }
+ },
+
+ async get(id: string): Promise {
+ const result = await api.execute('build-history:get', { id })
+ if (result.type === 'error') {
+ throw new Error(result.ipcError || 'Failed to get build history entry')
+ }
+ return result.result.entry
+ },
+
+ async getAll(query?: BuildHistoryQuery): Promise {
+ const result = await api.execute('build-history:get-all', {
+ query
+ })
+ console.log('result', result)
+ if (result.type === 'error') {
+ throw new Error(result.ipcError || 'Failed to get build history entries')
+ }
+ return result.result
+ },
+
+ async update(id: string, updates: Partial): Promise {
+ const result = await api.execute('build-history:update', {
+ id,
+ updates
+ })
+ if (result.type === 'error') {
+ throw new Error(result.ipcError || 'Failed to update build history entry')
+ }
+ },
+
+ async delete(id: string): Promise {
+ const result = await api.execute('build-history:delete', { id })
+ if (result.type === 'error') {
+ throw new Error(result.ipcError || 'Failed to delete build history entry')
+ }
+ },
+
+ async clear(): Promise {
+ const result = await api.execute('build-history:clear')
+ if (result.type === 'error') {
+ throw new Error(result.ipcError || 'Failed to clear build history')
+ }
+ },
+
+ async getStorageInfo(): Promise<{
+ totalEntries: number
+ totalSize: number
+ oldestEntry?: number
+ newestEntry?: number
+ }> {
+ const result = await api.execute('build-history:get-storage-info')
+ if (result.type === 'error') {
+ throw new Error(result.ipcError || 'Failed to get build history storage info')
+ }
+ return result.result
+ }
+ }
+
+ // State
+ const entries = ref([])
+ const currentEntry = ref()
+ const isLoading = ref(false)
+ const error = ref()
+ const storageInfo = ref<
+ | {
+ totalEntries: number
+ totalSize: number
+ oldestEntry?: number
+ newestEntry?: number
+ }
+ | undefined
+ >()
+
+ // Filtering state
+ const currentPipelineId = ref()
+ const currentScenarioId = ref()
+
+ // Computed
+ const hasEntries = computed(() => entries.value.length > 0)
+ const totalEntries = computed(() => entries.value.length)
+ const canUseHistory = computed(() => hasBuildHistoryBenefit.value)
+
+ const setError = (errorMessage: string) => {
+ error.value = errorMessage
+ logger.logger().error('[Build History]', errorMessage)
+ }
+
+ const buildQuery = (): BuildHistoryQuery => ({
+ pipelineId: currentPipelineId.value
+ })
+
+ // Actions
+ const loadEntries = async (query?: BuildHistoryQuery): Promise => {
+ console.trace('query', query)
+ isLoading.value = true
+
+ // Check authorization before attempting to load
+ if (!canUseHistory.value) {
+ isLoading.value = false
+ return
+ }
+
+ try {
+ const response = await buildHistoryAPI.getAll(query)
+ entries.value = response.entries
+ storageInfo.value = {
+ totalEntries: response.total,
+ totalSize: 0, // This would need to be calculated separately
+ oldestEntry:
+ response.total > 0 ? Math.min(...response.entries.map((e) => e.startTime)) : undefined,
+ newestEntry:
+ response.total > 0 ? Math.max(...response.entries.map((e) => e.startTime)) : undefined
+ }
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Failed to load build history entries'
+ setError(errorMessage)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const loadEntry = async (id: string): Promise => {
+ isLoading.value = true
+
+ // Check authorization before attempting to load
+ if (!canUseHistory.value) {
+ isLoading.value = false
+ return undefined
+ }
+
+ try {
+ const entry = await buildHistoryAPI.get(id)
+ if (entry) {
+ currentEntry.value = entry
+ // Update entry in the entries list if it exists
+ const index = entries.value.findIndex((e) => e.id === id)
+ if (index >= 0) {
+ entries.value[index] = entry
+ }
+ }
+ return entry
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load build history entry'
+ setError(errorMessage)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const saveEntry = async (entry: BuildHistoryEntry): Promise => {
+ isLoading.value = true
+
+ // Check authorization before attempting to save
+ if (!canUseHistory.value) {
+ isLoading.value = false
+ return
+ }
+
+ try {
+ await buildHistoryAPI.save(entry)
+
+ // Add or update entry in the local state
+ const existingIndex = entries.value.findIndex((e) => e.id === entry.id)
+ if (existingIndex >= 0) {
+ entries.value[existingIndex] = entry
+ } else {
+ entries.value.unshift(entry)
+ }
+
+ // Update current entry if it's the same
+ if (currentEntry.value?.id === entry.id) {
+ currentEntry.value = entry
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to save build history entry'
+ setError(errorMessage)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const updateEntry = async (id: string, updates: Partial): Promise => {
+ isLoading.value = true
+
+ // Check authorization before attempting to update
+ if (!canUseHistory.value) {
+ isLoading.value = false
+ return
+ }
+
+ try {
+ await buildHistoryAPI.update(id, updates)
+
+ // Update entry in local state
+ const index = entries.value.findIndex((e) => e.id === id)
+ if (index >= 0) {
+ entries.value[index] = { ...entries.value[index], ...updates, updatedAt: Date.now() }
+ }
+
+ // Update current entry if it's the same
+ if (currentEntry.value?.id === id) {
+ currentEntry.value = { ...currentEntry.value, ...updates, updatedAt: Date.now() }
+ }
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Failed to update build history entry'
+ setError(errorMessage)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const deleteEntry = async (id: string): Promise => {
+ isLoading.value = true
+
+ // Check authorization before attempting to delete
+ if (!canUseHistory.value) {
+ isLoading.value = false
+ return
+ }
+
+ try {
+ await buildHistoryAPI.delete(id)
+
+ // Remove from local state
+ const index = entries.value.findIndex((e) => e.id === id)
+ if (index >= 0) {
+ entries.value.splice(index, 1)
+ }
+
+ // Clear current entry if it's the same
+ if (currentEntry.value?.id === id) {
+ currentEntry.value = undefined
+ }
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Failed to delete build history entry'
+ setError(errorMessage)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const clearHistory = async (): Promise => {
+ isLoading.value = true
+
+ // Check authorization before attempting to clear
+ if (!canUseHistory.value) {
+ isLoading.value = false
+ return
+ }
+
+ try {
+ await buildHistoryAPI.clear()
+ entries.value = []
+ currentEntry.value = undefined
+ storageInfo.value = undefined
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to clear build history'
+ setError(errorMessage)
+ throw err
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ const refreshStorageInfo = async (): Promise => {
+ // Check authorization before attempting to get storage info
+ if (!canUseHistory.value) {
+ return
+ }
+
+ try {
+ storageInfo.value = await buildHistoryAPI.getStorageInfo()
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to refresh storage info'
+ setError(errorMessage)
+ }
+ }
+
+ const setCurrentPipeline = (pipelineId: string | undefined): void => {
+ currentPipelineId.value = pipelineId
+ }
+
+ const clearCurrentPipeline = (): void => {
+ currentPipelineId.value = undefined
+ }
+
+ const setCurrentScenario = (scenarioId: string | undefined): void => {
+ currentScenarioId.value = scenarioId
+ }
+
+ const clearCurrentScenario = (): void => {
+ currentScenarioId.value = undefined
+ }
+
+ return {
+ // State
+ entries: entries,
+ currentEntry: readonly(currentEntry),
+ isLoading: readonly(isLoading),
+ error: readonly(error),
+ storageInfo: readonly(storageInfo),
+
+ // Computed
+ hasEntries,
+ totalEntries,
+ canUseHistory,
+
+ // Pipeline filtering
+ currentPipelineId: readonly(currentPipelineId),
+ currentScenarioId: readonly(currentScenarioId),
+
+ // Actions
+ loadEntries,
+ loadEntry,
+ saveEntry,
+ updateEntry,
+ deleteEntry,
+ clearHistory,
+ refreshStorageInfo,
+ setCurrentPipeline,
+ clearCurrentPipeline,
+ setCurrentScenario,
+ clearCurrentScenario,
+
+ // Query builder
+ buildQuery
+ }
+})
diff --git a/src/renderer/store/editor.ts b/src/renderer/store/editor.ts
index c58d4ba1..7aaf1410 100644
--- a/src/renderer/store/editor.ts
+++ b/src/renderer/store/editor.ts
@@ -29,7 +29,6 @@ import { useFiles } from './files'
import { useRouteParams } from '@vueuse/router'
import { ValidationError } from '@renderer/models/error'
import { isRequired } from '@@/validation'
-import { processGraph } from '@@/graph'
import { useLogger } from '@@/logger'
import { klona } from 'klona'
import { create } from 'mutative'
@@ -743,7 +742,6 @@ export const useEditor = defineStore('editor', () => {
removeVariable,
getPluginDefinition,
getNodeDefinition,
- processGraph,
loadPreset,
isRunning,
setIsRunning,
diff --git a/src/shared/apis.ts b/src/shared/apis.ts
index 3f58638e..c62b26bf 100644
--- a/src/shared/apis.ts
+++ b/src/shared/apis.ts
@@ -2,6 +2,13 @@ import { RendererPluginDefinition } from '@pipelab/plugin-core'
import type { Tagged } from 'type-fest'
import { PresetResult, Steps } from './model'
import { AppConfig } from '@main/config'
+import {
+ BuildHistoryEntry,
+ BuildHistoryQuery,
+ BuildHistoryResponse,
+ BuildHistoryConfig,
+ RetentionPolicy
+} from './build-history'
type Event =
| { type: TYPE; data: DATA }
@@ -16,6 +23,7 @@ type EndEvent = {
| {
type: 'error'
ipcError: string
+ code?: string
}
}
@@ -99,6 +107,45 @@ export type IpcDefinition = {
'action:cancel': [void, EndEvent<{ result: 'ok' | 'ko' }>]
'settings:load': [never, EndEvent<{ result: AppConfig }>]
'settings:save': [AppConfig, EndEvent<{ result: 'ok' | 'ko' }>]
+
+ // Build History APIs
+ 'build-history:save': [{ entry: BuildHistoryEntry }, EndEvent<{ result: 'ok' | 'ko' }>]
+ 'build-history:get': [{ id: string }, EndEvent<{ entry?: BuildHistoryEntry }>]
+ 'build-history:get-all': [{ query?: BuildHistoryQuery }, EndEvent]
+ 'build-history:update': [
+ { id: string; updates: Partial },
+ EndEvent<{ result: 'ok' | 'ko' }>
+ ]
+ 'build-history:delete': [{ id: string }, EndEvent<{ result: 'ok' | 'ko' }>]
+ 'build-history:clear': [void, EndEvent<{ result: 'ok' | 'ko' }>]
+ 'build-history:get-storage-info': [
+ void,
+ EndEvent<{
+ totalEntries: number
+ totalSize: number
+ oldestEntry?: number
+ newestEntry?: number
+ }>
+ ]
+ 'build-history:configure': [
+ { config: Partial },
+ EndEvent<{ result: 'ok' | 'ko' }>
+ ]
+ 'graph:execute': [
+ {
+ graph: any[]
+ variables: any[]
+ pipelineId?: string
+ projectName?: string
+ projectPath?: string
+ },
+ (
+ | { type: 'node-enter'; data: { nodeUid: string; nodeName: string } }
+ | { type: 'node-exit'; data: { nodeUid: string; nodeName: string } }
+ | { type: 'node-log'; data: { nodeUid: string; logData: any } }
+ | EndEvent<{ result: any; buildId: string }>
+ )
+ ]
}
export type Channels = keyof IpcDefinition
diff --git a/src/shared/build-history.ts b/src/shared/build-history.ts
new file mode 100644
index 00000000..a238cc5d
--- /dev/null
+++ b/src/shared/build-history.ts
@@ -0,0 +1,119 @@
+// Build History Storage Types and Interfaces
+
+export interface ExecutionStep {
+ id: string
+ name: string
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
+ startTime: number
+ endTime?: number
+ duration?: number
+ logs: LogEntry[]
+ error?: ExecutionError
+ output?: Record
+}
+
+export interface ExecutionError {
+ message: string
+ stack?: string
+ code?: string
+ timestamp: number
+}
+
+export interface LogEntry {
+ id: string
+ timestamp: number
+ level: 'debug' | 'info' | 'warn' | 'error'
+ message: string
+ source?: string
+ data?: Record
+}
+
+export interface BuildHistoryEntry {
+ id: string
+ pipelineId: string
+ projectName: string
+ projectPath: string
+ status: 'running' | 'completed' | 'failed' | 'cancelled'
+ startTime: number
+ endTime?: number
+ duration?: number
+ steps: ExecutionStep[]
+ totalSteps: number
+ completedSteps: number
+ failedSteps: number
+ cancelledSteps: number
+ logs: LogEntry[]
+ error?: ExecutionError
+ output?: Record
+ metadata?: Record
+ userId?: string
+ createdAt: number
+ updatedAt: number
+}
+
+// Query interface supporting both pipeline and scenario filtering
+export interface BuildHistoryQuery {
+ pipelineId?: string
+}
+
+export interface BuildHistoryResponse {
+ entries: BuildHistoryEntry[]
+ total: number
+}
+
+// Storage interfaces - Simplified for pipeline-specific storage
+export interface IBuildHistoryStorage {
+ save(entry: BuildHistoryEntry): Promise
+ get(id: string): Promise
+ getAll(): Promise
+ getByPipeline(pipelineId: string): Promise
+ update(id: string, updates: Partial): Promise
+ delete(id: string): Promise
+ clear(): Promise
+ getStorageInfo(): Promise<{
+ totalEntries: number
+ totalSize: number
+ oldestEntry?: number
+ newestEntry?: number
+ }>
+}
+
+// Retention policy configuration
+export interface RetentionPolicy {
+ enabled: boolean
+ maxEntries: number
+ maxAge: number // in milliseconds
+ maxSize: number // in bytes
+ keepFailedBuilds: boolean
+ keepSuccessfulBuilds: boolean
+}
+
+// Storage configuration
+export interface BuildHistoryConfig {
+ storagePath: string
+ indexFileName: string
+ entryFilePrefix: string
+ retentionPolicy: RetentionPolicy
+}
+
+// Authorization and subscription types
+export interface SubscriptionBenefit {
+ id: string
+ name: string
+ description?: string
+}
+
+export interface SubscriptionError extends Error {
+ code: 'SUBSCRIPTION_REQUIRED' | 'SUBSCRIPTION_EXPIRED' | 'BENEFIT_NOT_FOUND' | 'UNAUTHORIZED'
+ benefit?: string
+ userMessage: string
+}
+
+export interface AuthorizationContext {
+ userId?: string
+ hasBenefit: (benefitId: string) => boolean
+ isPaidUser: boolean
+}
+
+// Authorization check function type
+export type AuthorizationCheck = (context: AuthorizationContext) => void
diff --git a/src/shared/i18n/en_US.json b/src/shared/i18n/en_US.json
index 949eccef..6144fcd9 100644
--- a/src/shared/i18n/en_US.json
+++ b/src/shared/i18n/en_US.json
@@ -40,6 +40,9 @@
"billing": "Billing",
"team": "Team"
},
+ "navigation": {
+ "build-history": "Build History"
+ },
"base": {
"close": "Close",
"save": "Save",
@@ -59,7 +62,8 @@
"execution-failed": "Execution failed",
"project-has-encountered-an-error": "Project has encountered an error:",
"project-saved": "Project saved",
- "your-project-has-be-saved-successfully": "Your project has be saved successfully"
+ "your-project-has-be-saved-successfully": "Your project has be saved successfully",
+ "view-history": "Scenario history"
},
"home": {
"invalid-preset": "Invalid preset",
diff --git a/src/shared/subscription-errors.ts b/src/shared/subscription-errors.ts
new file mode 100644
index 00000000..5d7a025a
--- /dev/null
+++ b/src/shared/subscription-errors.ts
@@ -0,0 +1,87 @@
+import { SubscriptionError } from './build-history'
+
+export class SubscriptionRequiredError extends Error implements SubscriptionError {
+ public readonly code = 'SUBSCRIPTION_REQUIRED' as const
+ public benefit: string
+ public userMessage: string
+
+ constructor(benefit?: string) {
+ super(`Subscription required${benefit ? ` for benefit: ${benefit}` : ''}`)
+ this.name = 'SubscriptionRequiredError'
+ this.benefit = benefit || 'build-history'
+ this.userMessage = `Build history is a premium feature. Please upgrade your subscription to access this feature.`
+ }
+}
+
+export class SubscriptionExpiredError extends Error implements SubscriptionError {
+ public readonly code = 'SUBSCRIPTION_EXPIRED' as const
+ public benefit: string
+ public userMessage: string
+
+ constructor(benefit?: string) {
+ super(`Subscription expired${benefit ? ` for benefit: ${benefit}` : ''}`)
+ this.name = 'SubscriptionExpiredError'
+ this.benefit = benefit || 'build-history'
+ this.userMessage = `Your subscription has expired. Please renew your subscription to continue using build history.`
+ }
+}
+
+export class BenefitNotFoundError extends Error implements SubscriptionError {
+ public readonly code = 'BENEFIT_NOT_FOUND' as const
+ public benefit: string
+ public userMessage: string
+
+ constructor(benefit: string) {
+ super(`Benefit not found: ${benefit}`)
+ this.name = 'BenefitNotFoundError'
+ this.benefit = benefit
+ this.userMessage = `The requested feature (${benefit}) is not available in your current subscription.`
+ }
+}
+
+export class UnauthorizedError extends Error implements SubscriptionError {
+ public readonly code = 'UNAUTHORIZED' as const
+ public benefit?: string
+ public userMessage: string
+
+ constructor(message?: string, benefit?: string) {
+ super(message || 'Unauthorized access')
+ this.name = 'UnauthorizedError'
+ this.benefit = benefit
+ this.userMessage = message || 'You do not have permission to access this feature.'
+ }
+}
+
+export function createSubscriptionError(
+ code: SubscriptionError['code'],
+ benefit?: string,
+ customMessage?: string
+): SubscriptionError {
+ switch (code) {
+ case 'SUBSCRIPTION_REQUIRED':
+ return new SubscriptionRequiredError(benefit)
+ case 'SUBSCRIPTION_EXPIRED':
+ return new SubscriptionExpiredError(benefit)
+ case 'BENEFIT_NOT_FOUND':
+ if (!benefit) throw new Error('Benefit is required for BENEFIT_NOT_FOUND error')
+ return new BenefitNotFoundError(benefit)
+ case 'UNAUTHORIZED':
+ return new UnauthorizedError(customMessage, benefit)
+ default:
+ throw new Error(`Unknown subscription error code: ${code}`)
+ }
+}
+
+export function isSubscriptionError(error: unknown): error is SubscriptionError {
+ return error instanceof Error && 'code' in error && 'userMessage' in error
+}
+
+export function getSubscriptionErrorMessage(error: unknown): string {
+ if (isSubscriptionError(error)) {
+ return error.userMessage
+ }
+ if (error instanceof Error) {
+ return error.message
+ }
+ return 'An unknown error occurred'
+}
diff --git a/src/shared/websocket.types.ts b/src/shared/websocket.types.ts
new file mode 100644
index 00000000..8125afa1
--- /dev/null
+++ b/src/shared/websocket.types.ts
@@ -0,0 +1,179 @@
+import { Channels, Data, Events, End, Message, RequestId } from './apis'
+import { WebSocket as WSWebSocket } from 'ws'
+
+// WebSocket connection states
+export type WebSocketConnectionState =
+ | 'connecting'
+ | 'connected'
+ | 'disconnected'
+ | 'error'
+ | 'reconnecting'
+
+// WebSocket server types
+export interface WebSocketServerConfig {
+ port?: number
+ host?: string
+ maxReconnectAttempts?: number
+ reconnectDelay?: number
+}
+
+export interface WebSocketServerEvents {
+ connection: (ws: WSWebSocket, request: IncomingMessage) => void
+ message: (ws: WSWebSocket, data: Buffer) => void
+ close: (ws: WSWebSocket) => void
+ error: (ws: WSWebSocket, error: Error) => void
+}
+
+// WebSocket client types
+export interface WebSocketClientConfig {
+ url?: string
+ maxReconnectAttempts?: number
+ reconnectDelay?: number
+ timeout?: number
+}
+
+export interface WebSocketClientEvents {
+ open: () => void
+ message: (event: MessageEvent) => void
+ close: (event: CloseEvent) => void
+ error: (error: Event) => void
+}
+
+// Unified WebSocket event types
+export interface WebSocketEvent {
+ sender: string
+ timestamp?: number
+}
+
+// WebSocket message types
+export interface WebSocketRequestMessage {
+ channel: Channels
+ requestId: RequestId
+ data: any
+}
+
+export interface WebSocketResponseMessage {
+ type: 'response'
+ requestId: RequestId
+ events: Events
+}
+
+export interface WebSocketErrorMessage {
+ type: 'error'
+ requestId: RequestId
+ error: string
+ code?: string
+}
+
+export type WebSocketMessage =
+ | WebSocketRequestMessage
+ | WebSocketResponseMessage
+ | WebSocketErrorMessage
+
+// WebSocket connection info
+export interface WebSocketConnectionInfo {
+ id: string
+ state: WebSocketConnectionState
+ url: string
+ connectedAt?: Date
+ lastActivity?: Date
+}
+
+// Handler function types
+export type WebSocketHandler = (
+ event: WebSocketEvent,
+ data: { value: Data; send: WebSocketSendFunction }
+) => Promise
+
+export type WebSocketSendFunction = (events: Events) => Promise
+
+// WebSocket listener types
+export type WebSocketListener = (event: Events) => Promise
+
+// WebSocket API interface
+export interface WebSocketAPI {
+ execute: (
+ channel: KEY,
+ data?: Data,
+ listener?: WebSocketListener
+ ) => Promise>
+ send: (channel: KEY, data?: Data) => Promise>
+ on: (
+ channel: KEY,
+ listener: (event: WebSocketEvent, data: Events) => void
+ ) => () => void
+ isConnected: () => boolean
+ disconnect: () => void
+}
+
+// WebSocket manager interface
+export interface WebSocketManager {
+ connect: (url?: string) => Promise
+ disconnect: () => void
+ send: (channel: KEY, data?: Data) => Promise>
+ isConnected: () => boolean
+ getConnectionState: () => WebSocketConnectionState
+ onStateChange: (callback: (state: WebSocketConnectionState) => void) => () => void
+}
+
+// Error types
+export class WebSocketError extends Error {
+ constructor(
+ message: string,
+ public code?: string,
+ public requestId?: RequestId
+ ) {
+ super(message)
+ this.name = 'WebSocketError'
+ }
+}
+
+export class WebSocketConnectionError extends WebSocketError {
+ constructor(
+ message: string,
+ public url?: string
+ ) {
+ super(message)
+ this.name = 'WebSocketConnectionError'
+ }
+}
+
+export class WebSocketTimeoutError extends WebSocketError {
+ constructor(
+ message: string,
+ public timeout: number,
+ requestId?: RequestId
+ ) {
+ super(message)
+ this.name = 'WebSocketTimeoutError'
+ }
+}
+
+// Type guards
+export const isWebSocketRequestMessage = (message: any): message is WebSocketRequestMessage => {
+ return message && typeof message.channel === 'string' && message.requestId
+}
+
+export const isWebSocketResponseMessage = (message: any): message is WebSocketResponseMessage => {
+ return message && message.type === 'response' && message.requestId && message.events
+}
+
+export const isWebSocketErrorMessage = (message: any): message is WebSocketErrorMessage => {
+ return message && message.type === 'error' && message.requestId && message.error
+}
+
+// Utility types for better type inference
+export type WebSocketMessageType = {
+ channel: T
+ requestId: RequestId
+ data: Data
+}
+
+export type WebSocketResponseType = {
+ type: 'response'
+ requestId: RequestId
+ events: Events
+}
+
+// Import for Node.js HTTP server
+import { IncomingMessage } from 'http'