diff --git a/.gitignore b/.gitignore index f9a295fa..f9e6d20d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ *.env .DS_Store /.agent-relay +*.tsbuildinfo diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ef90885e..c9da17bb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ renderer: { resolve: { alias: { - '@': resolve('src/renderer/src') + '@': resolve('src/renderer/src'), + '@shared': resolve('src/shared') } }, plugins: [react(), tailwindcss()] diff --git a/package-lock.json b/package-lock.json index 45ec8d00..e3f27006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "zod": "^3.25.76", "zustand": "^5.0.0" }, "devDependencies": { @@ -1224,7 +1225,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 15c67438..55fa1728 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "zod": "^3.25.76", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/src/main/auth.ts b/src/main/auth.ts index 20059882..16cdff7d 100644 --- a/src/main/auth.ts +++ b/src/main/auth.ts @@ -4,27 +4,16 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs' import { join } from 'path' import { URL } from 'url' import { cacheAvatarFromUrl, cachedAvatarUrl, isRemoteAvatarUrl } from './avatar-cache' +import { + AuthMetaSchema, + StoredTokensSchema, + UserInfoSchema, + type StoredTokens, + type UserInfo +} from './schemas' const CLOUD_API_URL = process.env.RELAY_CLOUD_URL || 'https://agentrelay.dev/cloud' -interface StoredTokens { - accessToken: string - refreshToken: string - apiUrl: string - expiresAt?: string - user?: UserInfo -} - -interface UserInfo { - name?: string - email?: string - githubUsername?: string - avatarUrl?: string - cachedAvatarUrl?: string - organizationName?: string - projectName?: string -} - interface AuthStatus { loggedIn: boolean apiUrl?: string @@ -53,46 +42,9 @@ function hasStoredTokens(): boolean { } } -function normalizeUserInfo(value: unknown): UserInfo | undefined { - if (!isRecord(value)) return undefined - - const record = value - const githubRecord = firstObject(record, [ - 'github', - 'githubUser', - 'github_user', - 'githubProfile', - 'github_profile', - 'githubAccount', - 'github_account' - ]) - const user: UserInfo = {} - const githubUsername = - firstString(record, ['githubUsername', 'github_username', 'githubLogin', 'github_login']) || - firstString(githubRecord, ['githubUsername', 'github_username', 'username', 'login']) || - firstString(record, ['username', 'login']) - const avatarUrl = - firstString(record, ['githubAvatarUrl', 'github_avatar_url']) || - firstString(githubRecord, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) || - firstString(record, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) - const cachedAvatarUrl = firstString(record, ['cachedAvatarUrl', 'cached_avatar_url']) - - const name = firstString(record, ['name', 'displayName', 'display_name']) - const email = firstString(record, ['email']) - const organizationName = firstString(record, ['organizationName', 'organization_name']) - const projectName = firstString(record, ['projectName', 'project_name']) - - if (name) user.name = name - if (email) user.email = email - if (githubUsername) user.githubUsername = githubUsername - if (avatarUrl) user.avatarUrl = avatarUrl - if (cachedAvatarUrl) user.cachedAvatarUrl = cachedAvatarUrl - if (organizationName) user.organizationName = organizationName - if (projectName) user.projectName = projectName - - return Object.keys(user).length > 0 ? user : undefined -} - +// The cloud API has historically returned the same logical field under several keys +// (camelCase vs snake_case, sometimes nested inside a `github` block). We tolerate +// all variants when normalizing, then validate the final shape with UserInfoSchema. function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } @@ -115,6 +67,48 @@ function firstObject(record: Record | undefined, keys: string[] return undefined } +const GITHUB_OBJECT_KEYS = [ + 'github', + 'githubUser', + 'github_user', + 'githubProfile', + 'github_profile', + 'githubAccount', + 'github_account' +] + +function normalizeUserInfo(value: unknown): UserInfo | undefined { + if (!isRecord(value)) return undefined + + const githubRecord = firstObject(value, GITHUB_OBJECT_KEYS) + const candidate = { + name: firstString(value, ['name', 'displayName', 'display_name']), + email: firstString(value, ['email']), + githubUsername: + firstString(value, ['githubUsername', 'github_username', 'githubLogin', 'github_login']) || + firstString(githubRecord, ['githubUsername', 'github_username', 'username', 'login']) || + firstString(value, ['username', 'login']), + avatarUrl: + firstString(value, ['githubAvatarUrl', 'github_avatar_url']) || + firstString(githubRecord, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) || + firstString(value, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']), + cachedAvatarUrl: firstString(value, ['cachedAvatarUrl', 'cached_avatar_url']), + organizationName: firstString(value, ['organizationName', 'organization_name']), + projectName: firstString(value, ['projectName', 'project_name']) + } + + const parsed = UserInfoSchema.parse(candidate) + // Zod preserves keys with `undefined` values from the input, so we strip + // them before returning. Without this, an empty whoami payload would shadow + // previously cached fields during `mergeUserInfo` (e.g. clearing the cached + // avatar URL on every refresh). + const user: UserInfo = {} + for (const [key, value] of Object.entries(parsed) as Array<[keyof UserInfo, string | undefined]>) { + if (value !== undefined) user[key] = value + } + return Object.keys(user).length > 0 ? user : undefined +} + function mergeUserInfo(previous: UserInfo | undefined, next: UserInfo | undefined): UserInfo | undefined { const normalizedPrevious = normalizeUserInfo(previous) const normalizedNext = normalizeUserInfo(next) @@ -140,10 +134,11 @@ function saveAuthMeta(tokens: Pick): void { function loadAuthMeta(): Pick { try { - const record = JSON.parse(readFileSync(getAuthMetaPath(), 'utf8')) as Record + const parsed = AuthMetaSchema.safeParse(JSON.parse(readFileSync(getAuthMetaPath(), 'utf8'))) + if (!parsed.success) return { apiUrl: CLOUD_API_URL } return { - apiUrl: typeof record.apiUrl === 'string' ? record.apiUrl : CLOUD_API_URL, - user: normalizeUserInfo(record.user) + apiUrl: parsed.data.apiUrl?.trim() || CLOUD_API_URL, + user: parsed.data.user } } catch { return { apiUrl: CLOUD_API_URL } @@ -197,9 +192,10 @@ function loadTokens(): StoredTokens | null { try { const raw = readFileSync(getAuthPath()) const decrypted = safeStorage.decryptString(raw) - const tokens = JSON.parse(decrypted) as StoredTokens - saveAuthMeta(tokens) - return tokens + const parsed = StoredTokensSchema.safeParse(JSON.parse(decrypted)) + if (!parsed.success) return null + saveAuthMeta(parsed.data) + return parsed.data } catch { return null } @@ -224,14 +220,13 @@ async function fetchWhoami(apiUrl: string, accessToken: string): Promise -} - type AvatarIdentity = { sourceUrl?: string githubUsername?: string @@ -41,14 +32,11 @@ function ensureCacheDir(): void { function loadManifest(): AvatarCacheManifest { try { - const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as AvatarCacheManifest - if (manifest.version === 1 && manifest.avatars && typeof manifest.avatars === 'object') { - return manifest - } + const parsed = AvatarCacheManifestSchema.safeParse(JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'))) + if (parsed.success) return parsed.data } catch { // Cache metadata is best-effort; corrupt or missing metadata just starts fresh. } - return { version: 1, avatars: {} } } diff --git a/src/main/broker.ts b/src/main/broker.ts index 326b428e..1890dcc4 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -16,6 +16,12 @@ import { } from '@agent-relay/sdk' import { getAccessToken, getApiUrl } from './auth' import { assertDirectory } from './path-utils' +import { + BrokerConnectionFileSchema, + GeneratedCommitDraftSchema, + type GeneratedCommitDraft +} from './schemas' +import { compactBrokerEvent, normalizeEventTimestamp } from '../shared/lib/broker-events' function isShellLikeCommand(cli: string): boolean { const normalized = basename(cli).toLowerCase() @@ -87,10 +93,7 @@ export interface AttachTerminalResult { } } -export interface GeneratedCommitDraft { - title: string - body: string -} +export type { GeneratedCommitDraft } export interface BrokerRuntimeAutoFixResult { removed: string[] @@ -105,7 +108,6 @@ const COMMIT_DRAFT_MAX_DIFF_CHARS = 80_000 const COMMIT_DRAFT_TIMEOUT_MS = 180_000 const MAX_BROKER_EVENT_HISTORY = 3_000 const BROKER_EVENT_HISTORY_TTL_MS = 12 * 60 * 60 * 1_000 -const MAX_EVENT_TEXT_CHARS = 1_200 function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -115,44 +117,19 @@ function toErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err) } -function normalizeEventTimestamp(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - return undefined - } - return value < 1_000_000_000_000 ? value * 1_000 : value -} - -function compactEventText(value: unknown): unknown { - if (typeof value !== 'string' || value.length <= MAX_EVENT_TEXT_CHARS) { - return value - } - return `${value.slice(0, MAX_EVENT_TEXT_CHARS)}...` -} - type BrokerEventRecordPayload = Record & { kind: string } -function compactBrokerEvent(event: BrokerEventRecordPayload): BrokerEventRecordPayload { - const compacted = { ...(event as Record) } - for (const key of ['body', 'chunk', 'message', 'reason', 'lastError']) { - if (key in compacted) { - compacted[key] = compactEventText(compacted[key]) - } - } - return compacted as BrokerEventRecordPayload +function getErrorStatus(err: unknown): unknown { + if (typeof err !== 'object' || err === null || !('status' in err)) return undefined + return (err as { status?: unknown }).status } function isUnsupportedInputStreamError(err: unknown): boolean { - const status = typeof err === 'object' && err !== null && 'status' in err - ? (err as { status?: unknown }).status - : undefined - return status === 404 || /\b404\b|not found|unsupported/i.test(toErrorMessage(err)) + return getErrorStatus(err) === 404 || /\b404\b|not found|unsupported/i.test(toErrorMessage(err)) } function isMissingAgentError(err: unknown): boolean { - const status = typeof err === 'object' && err !== null && 'status' in err - ? (err as { status?: unknown }).status - : undefined - return status === 404 || /agent_not_found|no worker named|not found/i.test(toErrorMessage(err)) + return getErrorStatus(err) === 404 || /agent_not_found|no worker named|not found/i.test(toErrorMessage(err)) } function withBrokerDetailsTimeout(promise: Promise, label: string): Promise { @@ -214,20 +191,21 @@ function getBrokerConnectionFileInfo( } try { - const connection = JSON.parse(readFileSync(connectionPath, 'utf-8')) as Record - const connectionUrl = normalizeBaseUrl(typeof connection.url === 'string' ? connection.url : undefined) - const connectionPid = typeof connection.pid === 'number' ? connection.pid : undefined - const connectionApiKey = typeof connection.api_key === 'string' && connection.api_key.trim().length > 0 - ? connection.api_key.trim() - : undefined + const parsed = BrokerConnectionFileSchema.safeParse( + JSON.parse(readFileSync(connectionPath, 'utf-8')) + ) + if (!parsed.success) { + return { path: connectionPath, status: 'invalid', hasApiKey: false } + } + const connectionUrl = normalizeBaseUrl(parsed.data.url) const sameUrl = !!baseUrl && connectionUrl === baseUrl - const samePid = !brokerPid || !connectionPid || connectionPid === brokerPid + const samePid = !brokerPid || !parsed.data.pid || parsed.data.pid === brokerPid return { path: connectionPath, status: sameUrl && samePid ? 'matches' : 'different', - hasApiKey: !!connectionApiKey, - apiKey: connectionApiKey + hasApiKey: !!parsed.data.apiKey, + apiKey: parsed.data.apiKey } } catch { return { path: connectionPath, status: 'invalid', hasApiKey: false } @@ -308,24 +286,12 @@ function getJsonObjectCandidates(text: string): string[] { return candidates } -function normalizeGeneratedCommitDraft(value: unknown): GeneratedCommitDraft | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const title = typeof record.title === 'string' ? record.title.trim() : '' - if (!title) return null - - return { - title, - body: typeof record.body === 'string' ? record.body.trim() : '' - } -} - function parseGeneratedCommitDraft(text: string): GeneratedCommitDraft { const candidates = getJsonObjectCandidates(text) for (const candidate of candidates.reverse()) { try { - const draft = normalizeGeneratedCommitDraft(JSON.parse(candidate)) - if (draft) return draft + const draft = GeneratedCommitDraftSchema.safeParse(JSON.parse(candidate)) + if (draft.success) return draft.data } catch { // Keep scanning terminal output; diffs can contain unrelated braces. } @@ -713,7 +679,8 @@ export class BrokerManager { } if (this.sessions.size === 1) { - return this.sessions.values().next().value + const onlySession = this.sessions.values().next().value + if (onlySession) return onlySession } throw new Error(`No relay workspace found for agent: ${name}`) @@ -736,9 +703,9 @@ export class BrokerManager { } else if ((event.kind === 'agent_exited' || event.kind === 'agent_released') && event.name) { this.closeInputStream(this.getInputStreamKey(projectId, event.name), 1000, 'agent closed') this.forgetAgentProject(event.name, projectId) - } else if (event.name) { + } else if ('name' in event && typeof event.name === 'string') { this.rememberAgentProject(event.name, projectId) - } else if (event.from) { + } else if ('from' in event && typeof event.from === 'string') { this.rememberAgentProject(event.from, projectId) } }) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 301b3a76..f228ed9e 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -175,10 +175,6 @@ export function registerIpcHandlers(): void { return brokerManager.connectCloud('cloud', win) }) - ipcMain.handle('broker:send-input', async (_, projectId: string | undefined, name: string, data: string) => { - await brokerManager.sendInput(projectId, name, data) - }) - ipcMain.on('broker:send-input-fast', (_, projectId: string | undefined, name: string, data: string) => { brokerManager.queueInput(projectId, name, data) }) diff --git a/src/main/schemas.ts b/src/main/schemas.ts new file mode 100644 index 00000000..5e26a859 --- /dev/null +++ b/src/main/schemas.ts @@ -0,0 +1,103 @@ +import { z } from 'zod' + +/** + * Schemas for data the main process loads from external sources: + * - JSON files on disk it wrote itself but that a user may have edited + * - JSON responses from cloud APIs + * + * All schemas accept unknown input and coerce / drop unrecognized fields. + */ + +const trimmedString = z + .string() + .transform((value) => value.trim()) + .pipe(z.string().min(1)) + +const optionalTrimmedString = z + .string() + .optional() + .transform((value) => value?.trim() || undefined) + +/* ----------------------------- Auth ----------------------------- */ + +export const UserInfoSchema = z + .object({ + name: optionalTrimmedString, + email: optionalTrimmedString, + githubUsername: optionalTrimmedString, + avatarUrl: optionalTrimmedString, + cachedAvatarUrl: optionalTrimmedString, + organizationName: optionalTrimmedString, + projectName: optionalTrimmedString + }) + .strip() + +export type UserInfo = z.infer + +export const StoredTokensSchema = z + .object({ + accessToken: trimmedString, + refreshToken: trimmedString, + apiUrl: trimmedString, + expiresAt: z.string().optional(), + user: UserInfoSchema.optional() + }) + .strip() + +export type StoredTokens = z.infer + +export const AuthMetaSchema = z + .object({ + apiUrl: z.string().optional(), + user: UserInfoSchema.optional() + }) + .strip() + +/* --------------------- Broker connection.json ------------------- */ + +export const BrokerConnectionFileSchema = z + .object({ + url: z.string().optional(), + pid: z.number().int().optional(), + api_key: z.string().optional() + }) + .passthrough() + .transform((value) => ({ + url: value.url?.trim() || undefined, + pid: value.pid, + apiKey: value.api_key?.trim() || undefined + })) + +/* ------------------ Generated commit draft JSON ----------------- */ + +export const GeneratedCommitDraftSchema = z + .object({ + title: trimmedString, + body: z.string().optional() + }) + .passthrough() + .transform((value) => ({ + title: value.title, + body: value.body?.trim() || '' + })) + +export type GeneratedCommitDraft = z.infer + +/* --------------------- Avatar cache manifest -------------------- */ + +export const AvatarCacheEntrySchema = z.object({ + key: z.string(), + sourceUrl: z.string(), + fileName: z.string(), + contentType: z.string(), + byteLength: z.number().int().nonnegative(), + updatedAt: z.string() +}) + +export const AvatarCacheManifestSchema = z.object({ + version: z.literal(1), + avatars: z.record(AvatarCacheEntrySchema) +}) + +export type AvatarCacheManifest = z.infer +export type AvatarCacheEntry = z.infer diff --git a/src/main/store.ts b/src/main/store.ts index 0719098c..b9d7d7e3 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -1,34 +1,25 @@ import { app } from 'electron' import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'fs' import { basename, join } from 'path' +import { z } from 'zod' +import { + PeopleListSchema, + ProjectIntegrationSchema, + ProjectRootSchema, + StoreDataSchema, + makeProjectSchema, + normalizeChannelName +} from '../shared/schemas/project' + +const ProjectSchema = makeProjectSchema(ProjectRootSchema) +const StoreSchema = StoreDataSchema(ProjectSchema) + +export type ProjectRoot = z.infer +export type ProjectIntegration = z.infer +export type Project = z.infer +type StoreData = z.infer -export interface ProjectRoot { - id: string - name: string - path: string -} - -export interface ProjectIntegration { - id: string - name: string - type: string -} - -export interface Project { - id: string - name: string - relayWorkspaceId: string - rootPath: string - roots: ProjectRoot[] - channels: string[] - channelPeople: Record - integrations: ProjectIntegration[] -} - -interface StoreData { - projects: Project[] - activeProjectId: string | null -} +const defaultData: StoreData = { projects: [], activeProjectId: null } const getStorePath = (): string => { const dir = join(app.getPath('userData'), 'config') @@ -36,158 +27,21 @@ const getStorePath = (): string => { return join(dir, 'projects.json') } -const defaultData: StoreData = { projects: [], activeProjectId: null } - function defaultRootName(path: string): string { return basename(path) || path } -function normalizeRoot(value: unknown): ProjectRoot | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const path = typeof record.path === 'string' ? record.path : null - if (!path) return null - - return { - id: typeof record.id === 'string' ? record.id : path, - name: typeof record.name === 'string' && record.name.trim() - ? record.name.trim() - : defaultRootName(path), - path - } -} - -function normalizeIntegration(value: unknown): ProjectIntegration | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const name = typeof record.name === 'string' ? record.name.trim() : '' - if (!name) return null - - return { - id: typeof record.id === 'string' ? record.id : crypto.randomUUID(), - name, - type: typeof record.type === 'string' && record.type.trim() ? record.type.trim() : 'custom' - } -} - -function normalizeChannelName(value: string): string { - return value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') -} - -function normalizeChannels(value: unknown): string[] { - const channels = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const deduped = Array.from(new Set(channels.map(normalizeChannelName).filter(Boolean))) - return deduped.length > 0 ? deduped : ['general'] -} - -function normalizePeopleList(value: unknown): string[] { - const people = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const names = people.map((entry) => entry.trim()).filter(Boolean) - return Array.from(new Map(names.map((name) => [name.toLowerCase(), name])).values()) -} - -function normalizeChannelPeople(value: unknown, channels: string[]): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - - const channelSet = new Set(channels) - const result: Record = {} - for (const [rawChannelName, rawPeople] of Object.entries(value as Record)) { - const channelName = normalizeChannelName(rawChannelName) - if (!channelName || !channelSet.has(channelName)) continue - - const people = normalizePeopleList(rawPeople) - if (people.length > 0) { - result[channelName] = people - } - } - - return result -} - -function dedupeRoots(roots: ProjectRoot[]): ProjectRoot[] { - const seen = new Set() - const deduped: ProjectRoot[] = [] - - for (const root of roots) { - if (seen.has(root.path)) continue - seen.add(root.path) - deduped.push(root) - } - - return deduped -} - -function normalizeProject(value: unknown): Project | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const id = typeof record.id === 'string' ? record.id : null - const name = typeof record.name === 'string' ? record.name : null - const relayWorkspaceId = typeof record.relayWorkspaceId === 'string' && record.relayWorkspaceId.trim() - ? record.relayWorkspaceId.trim() - : id || '' - const rootPath = typeof record.rootPath === 'string' ? record.rootPath : null - const roots = Array.isArray(record.roots) - ? dedupeRoots(record.roots.map(normalizeRoot).filter((entry): entry is ProjectRoot => entry !== null)) - : [] - const primaryRootPath = rootPath || roots[0]?.path || null - const integrations = Array.isArray(record.integrations) - ? record.integrations - .map(normalizeIntegration) - .filter((entry): entry is ProjectIntegration => entry !== null) - : [] - - if (!id || !name || !primaryRootPath || roots.length === 0) return null - - const channels = normalizeChannels(record.channels) - - return { - id, - name, - relayWorkspaceId, - rootPath: primaryRootPath, - roots, - channels, - channelPeople: normalizeChannelPeople(record.channelPeople, channels), - integrations - } -} - -function normalizeStore(raw: unknown): StoreData { - if (!raw || typeof raw !== 'object') return { ...defaultData } - const record = raw as Record - const projects = Array.isArray(record.projects) - ? record.projects.map(normalizeProject).filter((entry): entry is Project => entry !== null) - : [] - const activeProjectId = - typeof record.activeProjectId === 'string' || record.activeProjectId === null - ? record.activeProjectId - : null - - return { - projects, - activeProjectId - } -} - -export function loadStore(): StoreData { +function loadStoreFromDisk(): StoreData { try { const raw = readFileSync(getStorePath(), 'utf-8') - return normalizeStore(JSON.parse(raw)) + return StoreSchema.parse(JSON.parse(raw)) } catch { return { ...defaultData } } } +export const loadStore = loadStoreFromDisk + export function saveStore(data: StoreData): void { const storePath = getStorePath() const tmpPath = storePath + '.tmp' @@ -197,17 +51,12 @@ export function saveStore(data: StoreData): void { export function addProject(name: string, rootPath: string): Project { const data = loadStore() - const root: ProjectRoot = { - id: crypto.randomUUID(), - name: defaultRootName(rootPath), - path: rootPath - } const project: Project = { id: crypto.randomUUID(), name, relayWorkspaceId: crypto.randomUUID(), rootPath, - roots: [root], + roots: [{ id: crypto.randomUUID(), name: defaultRootName(rootPath), path: rootPath }], channels: ['general'], channelPeople: {}, integrations: [] @@ -230,40 +79,44 @@ export function setActiveProject(id: string | null): void { saveStore(data) } +function withProject(data: StoreData, projectId: string): Project | undefined { + return data.projects.find((entry) => entry.id === projectId) +} + export function addProjectChannel(projectId: string, channelName: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) - const normalizedName = normalizeChannelName(channelName) - if (project && normalizedName && !project.channels.includes(normalizedName)) { - project.channels.push(normalizedName) + const project = withProject(data, projectId) + const normalized = normalizeChannelName(channelName) + if (project && normalized && !project.channels.includes(normalized)) { + project.channels.push(normalized) saveStore(data) } } export function removeProjectChannel(projectId: string, channelName: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) - const normalizedName = normalizeChannelName(channelName) + const project = withProject(data, projectId) + const normalized = normalizeChannelName(channelName) if (project) { - project.channels = project.channels.filter((channel) => channel !== normalizedName) - delete project.channelPeople[normalizedName] + project.channels = project.channels.filter((channel) => channel !== normalized) + delete project.channelPeople[normalized] saveStore(data) } } export function setProjectChannelPeople(projectId: string, channelName: string, people: string[]): string[] { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) - const normalizedName = normalizeChannelName(channelName) - if (!project || !normalizedName || !project.channels.includes(normalizedName)) { + const project = withProject(data, projectId) + const normalized = normalizeChannelName(channelName) + if (!project || !normalized || !project.channels.includes(normalized)) { return [] } - const normalizedPeople = normalizePeopleList(people) + const normalizedPeople = PeopleListSchema.parse(people) if (normalizedPeople.length > 0) { - project.channelPeople[normalizedName] = normalizedPeople + project.channelPeople[normalized] = normalizedPeople } else { - delete project.channelPeople[normalizedName] + delete project.channelPeople[normalized] } saveStore(data) return normalizedPeople @@ -271,22 +124,19 @@ export function setProjectChannelPeople(projectId: string, channelName: string, export function addProjectRoot(projectId: string, rootPath: string, name?: string): ProjectRoot { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) { throw new Error('Project not found') } const existing = project.roots.find((root) => root.path === rootPath) - if (existing) { - return existing - } + if (existing) return existing const root: ProjectRoot = { id: crypto.randomUUID(), name: name?.trim() || defaultRootName(rootPath), path: rootPath } - project.roots.push(root) saveStore(data) return root @@ -294,7 +144,7 @@ export function addProjectRoot(projectId: string, rootPath: string, name?: strin export function removeProjectRoot(projectId: string, rootId: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) return if (project.roots.length <= 1) { @@ -314,21 +164,12 @@ export function addProjectIntegration( type = 'custom' ): ProjectIntegration { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) { throw new Error('Project not found') } - const integration: ProjectIntegration = { - id: crypto.randomUUID(), - name: name.trim(), - type: type.trim() || 'custom' - } - - if (!integration.name) { - throw new Error('Integration name is required') - } - + const integration = ProjectIntegrationSchema.parse({ name, type }) project.integrations.push(integration) saveStore(data) return integration @@ -336,7 +177,7 @@ export function addProjectIntegration( export function removeProjectIntegration(projectId: string, integrationId: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) return project.integrations = project.integrations.filter((integration) => integration.id !== integrationId) @@ -346,12 +187,13 @@ export function removeProjectIntegration(projectId: string, integrationId: strin export function updateProject(id: string, update: Partial): void { const data = loadStore() const idx = data.projects.findIndex((project) => project.id === id) - if (idx !== -1) { - const next = { ...data.projects[idx] } - if (typeof update.name === 'string' && update.name.trim()) { - next.name = update.name.trim() - } - data.projects[idx] = next - saveStore(data) + if (idx === -1) return + + const next = { ...data.projects[idx] } + if (typeof update.name === 'string' && update.name.trim()) { + next.name = update.name.trim() } + data.projects[idx] = next + saveStore(data) } + diff --git a/src/preload/index.ts b/src/preload/index.ts index 36ae5842..5a514f46 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,59 +2,56 @@ import { contextBridge, ipcRenderer } from 'electron' export type ViewMode = 'terminal' | 'chat' | 'graph' | 'project-settings' | 'broker-details' | 'source-control' -type AuthUser = { - name?: string - email?: string - githubUsername?: string - username?: string - avatarUrl?: string - cachedAvatarUrl?: string - organizationName?: string - projectName?: string +type TerminalAttachMode = 'view' | 'drive' | 'passthrough' + +// Thin generic wrappers so each handler binds an IPC channel + return type without +// repeating the `as Promise` cast on every call site. +function invoke(channel: string, ...args: unknown[]): Promise { + return ipcRenderer.invoke(channel, ...args) as Promise } -type AuthStatus = { - loggedIn: boolean - apiUrl?: string - user?: AuthUser +function subscribe(channel: string, callback: (payload: T) => void): () => void { + const handler = (_: unknown, payload: T): void => callback(payload) + ipcRenderer.on(channel, handler) + return () => ipcRenderer.removeListener(channel, handler) } const api = { project: { - list: () => ipcRenderer.invoke('project:list'), - add: (name: string, rootPath?: string) => ipcRenderer.invoke('project:add', name, rootPath), - remove: (id: string) => ipcRenderer.invoke('project:remove', id), - setActive: (id: string | null) => ipcRenderer.invoke('project:set-active', id), + list: () => invoke<{ projects: unknown[]; activeId: string | null }>('project:list'), + add: (name: string, rootPath?: string) => invoke('project:add', name, rootPath), + remove: (id: string) => invoke('project:remove', id), + setActive: (id: string | null) => invoke('project:set-active', id), update: (id: string, update: Record) => - ipcRenderer.invoke('project:update', id, update), + invoke('project:update', id, update), addChannel: (projectId: string, name: string) => - ipcRenderer.invoke('project:add-channel', projectId, name), + invoke('project:add-channel', projectId, name), removeChannel: (projectId: string, name: string) => - ipcRenderer.invoke('project:remove-channel', projectId, name), + invoke('project:remove-channel', projectId, name), setChannelPeople: (projectId: string, channelName: string, people: string[]) => - ipcRenderer.invoke('project:set-channel-people', projectId, channelName, people), + invoke('project:set-channel-people', projectId, channelName, people), addRoot: (projectId: string, name?: string, rootPath?: string) => - ipcRenderer.invoke('project:add-root', projectId, name, rootPath), + invoke('project:add-root', projectId, name, rootPath), removeRoot: (projectId: string, rootId: string) => - ipcRenderer.invoke('project:remove-root', projectId, rootId), + invoke('project:remove-root', projectId, rootId), addIntegration: (projectId: string, name: string, type?: string) => - ipcRenderer.invoke('project:add-integration', projectId, name, type), + invoke('project:add-integration', projectId, name, type), removeIntegration: (projectId: string, integrationId: string) => - ipcRenderer.invoke('project:remove-integration', projectId, integrationId) + invoke('project:remove-integration', projectId, integrationId) }, broker: { start: (projectId: string, cwd: string, name: string, channels?: string[]) => - ipcRenderer.invoke('broker:start', projectId, cwd, name, channels) as Promise, + invoke('broker:start', projectId, cwd, name, channels), syncChannels: (projectId: string, channels: string[]) => - ipcRenderer.invoke('broker:sync-channels', projectId, channels), + invoke('broker:sync-channels', projectId, channels), autoFixRuntime: ( projectId: string, cwd: string, name: string, channels?: string[], errorMessage?: string - ) => ipcRenderer.invoke('broker:auto-fix-runtime', projectId, cwd, name, channels, errorMessage) as Promise<{ removed: string[] }>, - connectCloud: () => ipcRenderer.invoke('broker:connect-cloud') as Promise, + ) => invoke<{ removed: string[] }>('broker:auto-fix-runtime', projectId, cwd, name, channels, errorMessage), + connectCloud: () => invoke('broker:connect-cloud'), spawnAgent: (projectId: string, input: { name: string cli: string @@ -62,88 +59,77 @@ const api = { task?: string channels?: string[] cwd?: string - }) => ipcRenderer.invoke('broker:spawn-agent', projectId, input), + }) => invoke<{ name: string; runtime: string }>('broker:spawn-agent', projectId, input), attachTerminal: (input: { projectId?: string name: string rows?: number cols?: number - mode?: 'view' | 'drive' | 'passthrough' - }) => ipcRenderer.invoke('broker:attach-terminal', input), - sendInput: (projectId: string | undefined, name: string, data: string) => - ipcRenderer.invoke('broker:send-input', projectId, name, data), + mode?: TerminalAttachMode + }) => invoke('broker:attach-terminal', input), sendInputFast: (projectId: string | undefined, name: string, data: string) => ipcRenderer.send('broker:send-input-fast', projectId, name, data), - setTerminalMode: (projectId: string | undefined, name: string, mode: 'view' | 'drive' | 'passthrough') => - ipcRenderer.invoke('broker:set-terminal-mode', projectId, name, mode), + setTerminalMode: (projectId: string | undefined, name: string, mode: TerminalAttachMode) => + invoke('broker:set-terminal-mode', projectId, name, mode), getPending: (projectId: string | undefined, name: string) => - ipcRenderer.invoke('broker:get-pending', projectId, name), + invoke('broker:get-pending', projectId, name), flushPending: (projectId: string | undefined, name: string) => - ipcRenderer.invoke('broker:flush-pending', projectId, name), + invoke<{ flushed: number }>('broker:flush-pending', projectId, name), resizePty: (projectId: string | undefined, name: string, rows: number, cols: number) => - ipcRenderer.invoke('broker:resize-pty', projectId, name, rows, cols), + invoke('broker:resize-pty', projectId, name, rows, cols), sendMessage: (projectId: string | undefined, input: { to: string; text: string; from?: string }) => - ipcRenderer.invoke('broker:send-message', projectId, input), + invoke('broker:send-message', projectId, input), subscribeAgentChannel: (projectId: string | undefined, name: string, channel: string) => - ipcRenderer.invoke('broker:subscribe-agent-channel', projectId, name, channel), + invoke('broker:subscribe-agent-channel', projectId, name, channel), unsubscribeAgentChannel: (projectId: string | undefined, name: string, channel: string) => - ipcRenderer.invoke('broker:unsubscribe-agent-channel', projectId, name, channel), + invoke('broker:unsubscribe-agent-channel', projectId, name, channel), releaseAgent: (projectId: string | undefined, name: string) => - ipcRenderer.invoke('broker:release-agent', projectId, name), - listAgents: (projectId?: string) => ipcRenderer.invoke('broker:list-agents', projectId), - listDetails: () => ipcRenderer.invoke('broker:list-details'), - listEvents: () => ipcRenderer.invoke('broker:list-events'), - shutdown: () => ipcRenderer.invoke('broker:shutdown'), - onEvent: (callback: (event: unknown) => void) => { - const handler = (_: unknown, event: unknown): void => callback(event) - ipcRenderer.on('broker:event', handler) - return () => ipcRenderer.removeListener('broker:event', handler) - }, - onStatus: (callback: (status: { projectId?: string; status: string; error?: string }) => void) => { - const handler = (_: unknown, status: { projectId?: string; status: string; error?: string }): void => - callback(status) - ipcRenderer.on('broker:status', handler) - return () => ipcRenderer.removeListener('broker:status', handler) - } + invoke('broker:release-agent', projectId, name), + listAgents: (projectId?: string) => invoke('broker:list-agents', projectId), + listDetails: () => invoke('broker:list-details'), + listEvents: () => invoke('broker:list-events'), + shutdown: () => invoke('broker:shutdown'), + onEvent: (callback: (event: unknown) => void) => subscribe('broker:event', callback), + onStatus: (callback: (status: { projectId?: string; status: string; error?: string }) => void) => + subscribe<{ projectId?: string; status: string; error?: string }>('broker:status', callback) }, git: { - status: (path: string) => ipcRenderer.invoke('git:status', path), - diff: (path: string, file?: string) => ipcRenderer.invoke('git:diff', path, file), + status: (path: string) => invoke('git:status', path), + diff: (path: string, file?: string) => invoke('git:diff', path, file), fileContent: (path: string, file: string, revision?: string) => - ipcRenderer.invoke('git:file-content', path, file, revision), - summary: (path: string) => ipcRenderer.invoke('git:summary', path), - branches: (root: string) => ipcRenderer.invoke('git:branches', root), - branchDetails: (root: string) => ipcRenderer.invoke('git:branch-details', root), + invoke('git:file-content', path, file, revision), + summary: (path: string) => invoke('git:summary', path), + branches: (root: string) => invoke('git:branches', root), + branchDetails: (root: string) => invoke('git:branch-details', root), checkoutBranch: (root: string, branch: string, options?: { stashChanges?: boolean }) => - ipcRenderer.invoke('git:checkout-branch', root, branch, options), - branchSyncStatus: (root: string) => ipcRenderer.invoke('git:branch-sync-status', root), - fetchRemote: (root: string) => ipcRenderer.invoke('git:fetch-remote', root), - pullCurrentBranch: (root: string) => ipcRenderer.invoke('git:pull-current-branch', root), - pushCurrentBranch: (root: string) => ipcRenderer.invoke('git:push-current-branch', root), - history: (path: string, limit?: number) => ipcRenderer.invoke('git:history', path, limit), - show: (path: string, hash: string, file?: string) => ipcRenderer.invoke('git:show', path, hash, file), - discardFiles: (path: string, files: string[]) => - ipcRenderer.invoke('git:discard-files', path, files), + invoke('git:checkout-branch', root, branch, options), + branchSyncStatus: (root: string) => invoke('git:branch-sync-status', root), + fetchRemote: (root: string) => invoke('git:fetch-remote', root), + pullCurrentBranch: (root: string) => invoke('git:pull-current-branch', root), + pushCurrentBranch: (root: string) => invoke('git:push-current-branch', root), + history: (path: string, limit?: number) => invoke('git:history', path, limit), + show: (path: string, hash: string, file?: string) => invoke('git:show', path, hash, file), + discardFiles: (path: string, files: string[]) => invoke('git:discard-files', path, files), addGitignorePatterns: (path: string, patterns: string[]) => - ipcRenderer.invoke('git:add-gitignore-patterns', path, patterns), + invoke('git:add-gitignore-patterns', path, patterns), commitSelection: (path: string, input: { title: string body?: string wholeFiles: string[] patch?: string - }) => ipcRenderer.invoke('git:commit-selection', path, input), + }) => invoke<{ hash: string }>('git:commit-selection', path, input), generateCommitMessage: (path: string, input: { wholeFiles: string[]; patch?: string }) => - ipcRenderer.invoke('git:generate-commit-message', path, input) + invoke('git:generate-commit-message', path, input) }, fs: { - listDir: (dirPath: string) => ipcRenderer.invoke('fs:list-dir', dirPath), - readPreview: (filePath: string) => ipcRenderer.invoke('fs:read-preview', filePath), - revealPath: (filePath: string) => ipcRenderer.invoke('fs:reveal-path', filePath) + listDir: (dirPath: string) => invoke('fs:list-dir', dirPath), + readPreview: (filePath: string) => invoke('fs:read-preview', filePath), + revealPath: (filePath: string) => invoke('fs:reveal-path', filePath) }, auth: { - login: () => ipcRenderer.invoke('auth:login') as Promise, - logout: () => ipcRenderer.invoke('auth:logout'), - status: () => ipcRenderer.invoke('auth:status') as Promise + login: () => invoke('auth:login'), + logout: () => invoke('auth:logout'), + status: () => invoke('auth:status') }, onMenu: (channel: string, callback: (...args: unknown[]) => void) => { const handler = (_: unknown, ...args: unknown[]): void => callback(...args) diff --git a/src/renderer/src/components/chat/ChatMessage.tsx b/src/renderer/src/components/chat/ChatMessage.tsx index 6e2a1074..a2255018 100644 --- a/src/renderer/src/components/chat/ChatMessage.tsx +++ b/src/renderer/src/components/chat/ChatMessage.tsx @@ -4,6 +4,7 @@ import { MessageCircle, SmilePlus } from 'lucide-react' import { AgentHarnessIcon } from '@/components/common/AgentIcons' import type { AuthUser } from '@/lib/ipc' import { renderChatMessageBody } from '@/lib/chat-formatting' +import { formatClockTime, formatRelativeShort } from '@/lib/format' import { useAgentStore } from '@/stores/agent-store' import type { ChatMessage as ChatMessageType, @@ -43,25 +44,6 @@ function getAgentColor(name: string): string { return agentColors[Math.abs(hash) % agentColors.length] } -function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) -} - -function formatReplyTime(timestamp: number): string { - const now = Date.now() - const ageMs = now - timestamp - if (ageMs >= 0 && ageMs < 60_000) return 'just now' - if (ageMs >= 0 && ageMs < 3_600_000) { - const minutes = Math.max(1, Math.floor(ageMs / 60_000)) - return `${minutes}m ago` - } - if (ageMs >= 0 && ageMs < 86_400_000) { - const hours = Math.max(1, Math.floor(ageMs / 3_600_000)) - return `${hours}h ago` - } - return new Date(timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' }) -} - function participantKey(participant: Pick): string { return `${participant.projectId || 'unknown'}:${participant.isHuman ? 'human' : participant.from}` } @@ -257,7 +239,7 @@ function ThreadSummary({ {lastReply && ( - {formatReplyTime(lastReply.timestamp)} + {formatRelativeShort(lastReply.timestamp)} )} @@ -289,7 +271,7 @@ export function ChatMessage({
{message.body} - {formatTime(message.timestamp)} + {formatClockTime(message.timestamp)}
) @@ -329,7 +311,7 @@ export function ChatMessage({ {message.isHuman ? 'You' : message.from} - {formatTime(message.timestamp)} + {formatClockTime(message.timestamp)} {showRoute && message.to && !message.isHuman && ( diff --git a/src/renderer/src/components/chat/ThreadPanel.tsx b/src/renderer/src/components/chat/ThreadPanel.tsx index 42b5bb34..88c72532 100644 --- a/src/renderer/src/components/chat/ThreadPanel.tsx +++ b/src/renderer/src/components/chat/ThreadPanel.tsx @@ -4,6 +4,7 @@ import { X } from 'lucide-react' import { AgentHarnessIcon } from '@/components/common/AgentIcons' import type { AuthUser } from '@/lib/ipc' import { renderChatMessageBody } from '@/lib/chat-formatting' +import { formatClockTime } from '@/lib/format' import { useAgentStore } from '@/stores/agent-store' import type { ChatMessage as ChatMessageType, @@ -21,10 +22,6 @@ interface ThreadPanelProps { onReply: (messageId: string, body: string) => void } -function formatTime(timestamp: number): string { - return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) -} - function ReplyAvatar({ reply, authUser @@ -71,7 +68,7 @@ function ThreadReplyRow({ {reply.isHuman ? 'You' : reply.from} - {formatTime(reply.timestamp)} + {formatClockTime(reply.timestamp)}
{renderChatMessageBody(reply.body)} diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index 9f2bab88..0cc1a5a7 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -6,3 +6,19 @@ const compactCountFormatter = new Intl.NumberFormat('en-US', { export function formatGitDiffLineCount(count: number): string { return compactCountFormatter.format(count).toLowerCase() } + +export function formatClockTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +const MINUTE_MS = 60_000 +const HOUR_MS = 3_600_000 +const DAY_MS = 86_400_000 + +export function formatRelativeShort(timestamp: number, now = Date.now()): string { + const ageMs = now - timestamp + if (ageMs >= 0 && ageMs < MINUTE_MS) return 'just now' + if (ageMs >= 0 && ageMs < HOUR_MS) return `${Math.max(1, Math.floor(ageMs / MINUTE_MS))}m ago` + if (ageMs >= 0 && ageMs < DAY_MS) return `${Math.max(1, Math.floor(ageMs / HOUR_MS))}h ago` + return new Date(timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' }) +} diff --git a/src/renderer/src/lib/ipc.ts b/src/renderer/src/lib/ipc.ts index 9f9399b9..9343a094 100644 --- a/src/renderer/src/lib/ipc.ts +++ b/src/renderer/src/lib/ipc.ts @@ -95,6 +95,15 @@ export interface BrokerEventRecord { } } +export type GitFileStatusKind = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' + +export interface GitFileStatus { + path: string + oldPath?: string + status: GitFileStatusKind + staged: boolean +} + export interface GitSummary { branch: string additions: number @@ -233,7 +242,6 @@ export interface PearAPI { screen: string } }> - sendInput: (projectId: string | undefined, name: string, data: string) => Promise<{ name: string; bytes_written: number }> sendInputFast: (projectId: string | undefined, name: string, data: string) => void setTerminalMode: (projectId: string | undefined, name: string, mode: TerminalAttachMode) => Promise<{ name: string @@ -256,7 +264,7 @@ export interface PearAPI { onStatus: (callback: (status: { projectId?: string; status: string; error?: string }) => void) => () => void } git: { - status: (path: string) => Promise<{ path: string; oldPath?: string; status: string; staged: boolean }[]> + status: (path: string) => Promise diff: (path: string, file?: string) => Promise fileContent: (path: string, file: string, revision?: string) => Promise summary: (path: string) => Promise diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index 57e9bf7f..db76deae 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -8,6 +8,10 @@ import type { TerminalAttachMode } from '@/lib/ipc' import { normalizeChannelName, useProjectStore } from '@/stores/project-store' +import { + compactBrokerEvent as compactBrokerEventPayload, + normalizeEventTimestamp +} from '@shared/lib/broker-events' export interface Agent { name: string @@ -75,7 +79,6 @@ const MAX_PTY_BUFFER_CHUNKS = 10_000 const MAX_BROKER_ERRORS = 12 const MAX_BROKER_EVENTS = 3_000 const BROKER_EVENT_RETENTION_MS = 12 * 60 * 60 * 1_000 -const MAX_BROKER_EVENT_TEXT_CHARS = 1_200 const HUMAN_SENDER_NAME = 'human' const SYSTEM_NOTICE_SENDER_NAME = 'system' const HUMAN_MESSAGE_DEDUPE_WINDOW_MS = 10_000 @@ -216,28 +219,8 @@ function clearPendingDeliveries(agent: Agent, eventId?: string): Agent { } } -function normalizeEventTimestamp(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - return undefined - } - return value < 1_000_000_000_000 ? value * 1_000 : value -} - -function compactBrokerEventText(value: unknown): unknown { - if (typeof value !== 'string' || value.length <= MAX_BROKER_EVENT_TEXT_CHARS) { - return value - } - return `${value.slice(0, MAX_BROKER_EVENT_TEXT_CHARS)}...` -} - function compactBrokerEvent(event: Record): BrokerEventRecord['event'] { - const compacted = { ...event } - for (const key of ['body', 'chunk', 'message', 'reason', 'lastError']) { - if (key in compacted) { - compacted[key] = compactBrokerEventText(compacted[key]) - } - } - return compacted as BrokerEventRecord['event'] + return compactBrokerEventPayload(event) as BrokerEventRecord['event'] } function pruneBrokerEvents(events: BrokerEventRecord[], now = Date.now()): BrokerEventRecord[] { diff --git a/src/renderer/src/stores/git-store.ts b/src/renderer/src/stores/git-store.ts index 91c53d88..1a01a2e0 100644 --- a/src/renderer/src/stores/git-store.ts +++ b/src/renderer/src/stores/git-store.ts @@ -3,16 +3,12 @@ import { pear, type GitCommitDraft, type GitCommitSelectionInput, + type GitFileStatus, type GitHistoryCommit, type GitSummary as IpcGitSummary } from '@/lib/ipc' -export interface FileStatus { - path: string - oldPath?: string - status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' - staged: boolean -} +export type FileStatus = GitFileStatus export interface ProjectFileStatus extends FileStatus { rootPath: string @@ -129,7 +125,7 @@ export const useGitStore = create((set, get) => ({ fetchStatus: async (path) => { try { - const files = (await pear.git.status(path)) as FileStatus[] + const files = await pear.git.status(path) if (!sameFileStatuses(get().files, files)) set({ files }) } catch { if (get().files.length > 0) set({ files: [] }) diff --git a/src/renderer/src/stores/project-store.ts b/src/renderer/src/stores/project-store.ts index 9722706a..26d8752a 100644 --- a/src/renderer/src/stores/project-store.ts +++ b/src/renderer/src/stores/project-store.ts @@ -1,29 +1,47 @@ import { create } from 'zustand' +import { z } from 'zod' +import { + ProjectIntegrationSchema, + ProjectRootSchema, + makeProjectSchema, + normalizeChannelName +} from '@shared/schemas/project' import { pear } from '@/lib/ipc' -export interface ProjectRoot { - id: string - name: string - path: string - pathExists: boolean +// Renderer enriches each root with `pathExists` populated by the main process. +const RendererProjectRootSchema = ProjectRootSchema.and( + z.object({ pathExists: z.boolean().optional() }) +).transform((value) => ({ + id: value.id, + name: value.name, + path: value.path, + pathExists: typeof value.pathExists === 'boolean' ? value.pathExists : true +})) + +const RendererProjectSchema = makeProjectSchema(RendererProjectRootSchema).transform((project) => ({ + ...project, + rootPathExists: project.roots.some((root) => root.path === project.rootPath && root.pathExists) +})) + +export type ProjectRoot = z.infer +export type ProjectIntegration = z.infer +export type Project = z.infer + +export { normalizeChannelName } + +function parseProject(value: unknown): Project | null { + const result = RendererProjectSchema.safeParse(value) + return result.success ? result.data : null } -export interface ProjectIntegration { - id: string - name: string - type: string +function parseIntegration(value: unknown): ProjectIntegration | null { + const result = ProjectIntegrationSchema.safeParse(value) + return result.success ? result.data : null } -export interface Project { - id: string - name: string - relayWorkspaceId: string - rootPath: string - rootPathExists: boolean - roots: ProjectRoot[] - channels: string[] - channelPeople: Record - integrations: ProjectIntegration[] +function parseRoot(value: unknown): ProjectRoot | null { + const result = RendererProjectRootSchema.safeParse(value) + return result.success ? result.data : null } function getBrokerChannels(project: Project | undefined): string[] { @@ -38,137 +56,11 @@ function getRelayWorkspaceName(project: Project): string { return `pear-${project.relayWorkspaceId}` } -function defaultRootName(path: string): string { - return path.split(/[\\/]/).filter(Boolean).pop() || path -} - -function normalizeRoot(value: unknown): ProjectRoot | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const path = typeof record.path === 'string' ? record.path : null - if (!path) return null - - return { - id: typeof record.id === 'string' ? record.id : path, - name: typeof record.name === 'string' && record.name.trim() - ? record.name.trim() - : defaultRootName(path), - path, - pathExists: typeof record.pathExists === 'boolean' ? record.pathExists : true - } -} - -function normalizeIntegration(value: unknown): ProjectIntegration | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const name = typeof record.name === 'string' ? record.name.trim() : '' - if (!name) return null - - return { - id: typeof record.id === 'string' ? record.id : crypto.randomUUID(), - name, - type: typeof record.type === 'string' && record.type.trim() ? record.type.trim() : 'custom' - } -} - -export function normalizeChannelName(value: string): string { - return value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') -} - -function normalizeChannels(value: unknown): string[] { - const channels = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const deduped = Array.from(new Set(channels.map(normalizeChannelName).filter(Boolean))) - return deduped.length > 0 ? deduped : ['general'] -} - -function normalizePeopleList(value: unknown): string[] { - const people = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const names = people.map((entry) => entry.trim()).filter(Boolean) - return Array.from(new Map(names.map((name) => [name.toLowerCase(), name])).values()) -} - -function normalizeChannelPeople(value: unknown, channels: string[]): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - - const channelSet = new Set(channels) - const result: Record = {} - for (const [rawChannelName, rawPeople] of Object.entries(value as Record)) { - const channelName = normalizeChannelName(rawChannelName) - if (!channelName || !channelSet.has(channelName)) continue - - const people = normalizePeopleList(rawPeople) - if (people.length > 0) { - result[channelName] = people - } - } - - return result -} - -function dedupeRoots(roots: ProjectRoot[]): ProjectRoot[] { - const seen = new Set() - const deduped: ProjectRoot[] = [] - - for (const root of roots) { - if (seen.has(root.path)) continue - seen.add(root.path) - deduped.push(root) - } - - return deduped -} - function getDefaultRoot(project: Project | undefined): ProjectRoot | undefined { if (!project) return undefined return project.roots.find((root) => root.pathExists) || project.roots[0] } -function normalizeProject(value: unknown): Project | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const id = typeof record.id === 'string' ? record.id : null - const name = typeof record.name === 'string' ? record.name : null - const relayWorkspaceId = typeof record.relayWorkspaceId === 'string' && record.relayWorkspaceId.trim() - ? record.relayWorkspaceId.trim() - : id || '' - const rootPath = typeof record.rootPath === 'string' ? record.rootPath : null - const roots = Array.isArray(record.roots) - ? dedupeRoots(record.roots.map(normalizeRoot).filter((entry): entry is ProjectRoot => entry !== null)) - : [] - const primaryRoot = rootPath || roots[0]?.path || null - const integrations = Array.isArray(record.integrations) - ? record.integrations - .map(normalizeIntegration) - .filter((entry): entry is ProjectIntegration => entry !== null) - : [] - - if (!id || !name || !primaryRoot || roots.length === 0) return null - - const channels = normalizeChannels(record.channels) - - return { - id, - name, - relayWorkspaceId, - rootPath: primaryRoot, - rootPathExists: roots.some((root) => root.path === primaryRoot && root.pathExists), - roots, - channels, - channelPeople: normalizeChannelPeople(record.channelPeople, channels), - integrations - } -} - interface ProjectState { projects: Project[] activeProjectId: string | null @@ -214,9 +106,10 @@ export const useProjectStore = create((set, get) => ({ load: async () => { set({ loading: true }) const data = await pear.project.list() - const projects = (data.projects as unknown[]) - .map(normalizeProject) - .filter((project): project is Project => project !== null) + const projects = data.projects.flatMap((raw) => { + const project = parseProject(raw) + return project ? [project] : [] + }) const activeProjectId = projects.some((project) => project.id === data.activeId) ? data.activeId : projects.find((project) => project.roots.some((root) => root.pathExists))?.id || null @@ -268,7 +161,7 @@ export const useProjectStore = create((set, get) => ({ }, addProject: async (name, rootPath) => { - const project = (await pear.project.add(name, rootPath)) as Project | null + const project = parseProject(await pear.project.add(name, rootPath)) if (project) { await pear.project.setActive(project.id) await get().load() @@ -311,7 +204,7 @@ export const useProjectStore = create((set, get) => ({ addRoot: async (name, rootPath) => { const { activeProjectId } = get() if (!activeProjectId) return null - const root = normalizeRoot(await pear.project.addRoot(activeProjectId, name, rootPath)) + const root = parseRoot(await pear.project.addRoot(activeProjectId, name, rootPath)) await get().load() if (root) { get().setActiveRoot(root.id) @@ -470,7 +363,7 @@ export const useProjectStore = create((set, get) => ({ addIntegration: async (name, type = 'custom') => { const { activeProjectId } = get() if (!activeProjectId || !name.trim()) return null - const integration = normalizeIntegration( + const integration = parseIntegration( await pear.project.addIntegration(activeProjectId, name.trim(), type) ) if (!integration) return null diff --git a/src/renderer/src/stores/ui-store.ts b/src/renderer/src/stores/ui-store.ts index 563d29b1..4264ddff 100644 --- a/src/renderer/src/stores/ui-store.ts +++ b/src/renderer/src/stores/ui-store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { z } from 'zod' import { getDirectMessageRoomId, getDirectMessageRoomTitle, @@ -7,8 +8,10 @@ import { export type ViewMode = 'terminal' | 'chat' | 'graph' | 'project-settings' | 'broker-details' | 'source-control' export type DialogType = 'add-project' | 'spawn-agent' | 'add-channel' | 'command-menu' | null -export type Theme = 'dark' | 'light' -export type TerminalLayout = 'tabs' | 'horizontal-split' | 'graph' +const ThemeSchema = z.enum(['dark', 'light']) +const TerminalLayoutSchema = z.enum(['tabs', 'horizontal-split', 'graph']) +export type Theme = z.infer +export type TerminalLayout = z.infer export type AppTabKind = 'agents' | 'channel' | 'dm' | 'project-settings' | 'broker-details' | 'source-control' export interface AppTab { @@ -61,22 +64,14 @@ function applyTheme(theme: Theme): void { localStorage.setItem('pear-theme', theme) } -const savedTheme = (typeof localStorage !== 'undefined' - ? localStorage.getItem('pear-theme') - : null) as Theme | null - -const initialTheme: Theme = savedTheme || 'dark' - -const savedTerminalLayout = (typeof localStorage !== 'undefined' - ? localStorage.getItem('pear-terminal-layout') - : null) as TerminalLayout | null +function readStored(key: string, schema: z.ZodType, fallback: T): T { + if (typeof localStorage === 'undefined') return fallback + const parsed = schema.safeParse(localStorage.getItem(key)) + return parsed.success ? parsed.data : fallback +} -const initialTerminalLayout: TerminalLayout = - savedTerminalLayout === 'tabs' || - savedTerminalLayout === 'horizontal-split' || - savedTerminalLayout === 'graph' - ? savedTerminalLayout - : 'horizontal-split' +const initialTheme = readStored('pear-theme', ThemeSchema, 'dark') +const initialTerminalLayout = readStored('pear-terminal-layout', TerminalLayoutSchema, 'horizontal-split') // Apply on load if (typeof document !== 'undefined') { diff --git a/src/shared/lib/broker-events.ts b/src/shared/lib/broker-events.ts new file mode 100644 index 00000000..71db21ea --- /dev/null +++ b/src/shared/lib/broker-events.ts @@ -0,0 +1,31 @@ +/** + * Shared helpers for sanitizing BrokerEvent payloads before storing them in + * memory or shipping them to the renderer. Used by both the main process + * (`src/main/broker.ts`) and the renderer agent store + * (`src/renderer/src/stores/agent-store.ts`). + */ + +const MAX_EVENT_TEXT_CHARS = 1_200 +const TRUNCATABLE_EVENT_KEYS = ['body', 'chunk', 'message', 'reason', 'lastError'] as const + +export function normalizeEventTimestamp(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return undefined + } + return value < 1_000_000_000_000 ? value * 1_000 : value +} + +function truncate(value: unknown): unknown { + if (typeof value !== 'string' || value.length <= MAX_EVENT_TEXT_CHARS) return value + return `${value.slice(0, MAX_EVENT_TEXT_CHARS)}...` +} + +export function compactBrokerEvent>(event: T): T { + const compacted = { ...event } + for (const key of TRUNCATABLE_EVENT_KEYS) { + if (key in compacted) { + compacted[key as keyof T] = truncate(compacted[key as keyof T]) as T[keyof T] + } + } + return compacted +} diff --git a/src/shared/schemas/project.ts b/src/shared/schemas/project.ts new file mode 100644 index 00000000..b9428c72 --- /dev/null +++ b/src/shared/schemas/project.ts @@ -0,0 +1,160 @@ +import { z } from 'zod' + +export function normalizeChannelName(value: string): string { + return value + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +function defaultRootName(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path +} + +const trimmedString = z.string().transform((value) => value.trim()) + +const stringArray = z + .unknown() + .transform((value): string[] => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] + ) + +const channelsSchema = stringArray.transform((values) => { + const deduped = Array.from(new Set(values.map(normalizeChannelName).filter(Boolean))) + return deduped.length > 0 ? deduped : ['general'] +}) + +const peopleListSchema = stringArray.transform((values) => { + const trimmed = values.map((entry) => entry.trim()).filter(Boolean) + return Array.from(new Map(trimmed.map((name) => [name.toLowerCase(), name])).values()) +}) + +const channelPeopleSchema = z + .unknown() + .transform((value): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + ) + +function buildChannelPeople(raw: Record, channels: string[]): Record { + const channelSet = new Set(channels) + const result: Record = {} + for (const [rawChannel, rawPeople] of Object.entries(raw)) { + const channel = normalizeChannelName(rawChannel) + if (!channel || !channelSet.has(channel)) continue + const people = peopleListSchema.parse(rawPeople) + if (people.length > 0) result[channel] = people + } + return result +} + +function dedupeRoots(roots: T[]): T[] { + const seen = new Set() + const out: T[] = [] + for (const root of roots) { + if (seen.has(root.path)) continue + seen.add(root.path) + out.push(root) + } + return out +} + +export const ProjectRootSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + path: z.string().min(1) + }) + .passthrough() + .transform((value) => ({ + id: value.id?.trim() || value.path, + name: value.name?.trim() || defaultRootName(value.path), + path: value.path + })) + +export const ProjectIntegrationSchema = z + .object({ + id: z.string().optional(), + name: trimmedString, + type: z.string().optional() + }) + .passthrough() + .refine((value) => value.name.length > 0, { message: 'integration name is required' }) + .transform((value) => ({ + id: value.id?.trim() || crypto.randomUUID(), + name: value.name, + type: value.type?.trim() || 'custom' + })) + +/** + * Build a project schema parameterized by the per-process root shape. + * + * Main stores `{ id, name, path }` on disk. Renderer adds `pathExists` + * computed at IPC time. + */ +export function makeProjectSchema( + rootSchema: z.ZodType +) { + return z + .object({ + id: z.string().min(1), + name: z.string().min(1), + relayWorkspaceId: z.string().optional(), + rootPath: z.string().optional(), + roots: z.array(z.unknown()).optional(), + channels: z.unknown().optional(), + channelPeople: z.unknown().optional(), + integrations: z.array(z.unknown()).optional() + }) + .passthrough() + .transform((value, ctx) => { + const roots = dedupeRoots( + (value.roots ?? []).flatMap((entry) => { + const parsed = rootSchema.safeParse(entry) + return parsed.success ? [parsed.data] : [] + }) + ) + const rootPath = value.rootPath?.trim() || roots[0]?.path || '' + if (!rootPath || roots.length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'project has no usable root' }) + return z.NEVER + } + const channels = channelsSchema.parse(value.channels) + const integrations = (value.integrations ?? []).flatMap((entry) => { + const parsed = ProjectIntegrationSchema.safeParse(entry) + return parsed.success ? [parsed.data] : [] + }) + const channelPeople = buildChannelPeople(channelPeopleSchema.parse(value.channelPeople), channels) + return { + id: value.id, + name: value.name, + relayWorkspaceId: value.relayWorkspaceId?.trim() || value.id, + rootPath, + roots, + channels, + channelPeople, + integrations + } + }) +} + +export const StoreDataSchema =

(projectSchema: z.ZodType) => + z + .object({ + projects: z.array(z.unknown()).optional(), + activeProjectId: z.union([z.string(), z.null()]).optional() + }) + .passthrough() + .transform((value) => ({ + projects: (value.projects ?? []).flatMap((entry) => { + const parsed = projectSchema.safeParse(entry) + return parsed.success ? [parsed.data] : [] + }), + activeProjectId: typeof value.activeProjectId === 'string' ? value.activeProjectId : null + })) + +export const PeopleListSchema = peopleListSchema diff --git a/tsconfig.node.json b/tsconfig.node.json index 3df47fd1..da2aac2c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -16,6 +16,7 @@ "include": [ "src/main/**/*", "src/preload/**/*", + "src/shared/**/*", "electron.vite.config.ts" ] } diff --git a/tsconfig.web.json b/tsconfig.web.json index f5d45970..91bc7796 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -8,15 +8,16 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "outDir": "./out", - "rootDir": "./src/renderer/src", + "rootDir": "./src", "strict": true, "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, "declaration": true, "paths": { - "@/*": ["./src/renderer/src/*"] + "@/*": ["./src/renderer/src/*"], + "@shared/*": ["./src/shared/*"] } }, - "include": ["src/renderer/src/**/*"] + "include": ["src/renderer/src/**/*", "src/shared/**/*"] }