diff --git a/src/main/proactive-agent.bundle.ts b/src/main/proactive-agent.bundle.ts new file mode 100644 index 00000000..50da3692 --- /dev/null +++ b/src/main/proactive-agent.bundle.ts @@ -0,0 +1,214 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { app } from 'electron' + +import type { DeployIO, DeployOptions, DeployResult } from '@agentworkforce/deploy' + +export type ProactiveAgentDeployPhase = 'validate' | 'bundle' | 'upload' | 'warm' | 'register' +export type ProactiveAgentDeployStatus = 'active' | 'warming' | 'error' + +export type ProactiveAgentDraft = { + id: string + name: string + description?: string + cloudAgentId: string + harness: 'claude' | 'codex' | 'opencode' + model: string + systemPrompt: string + integrations: Record> + watch: Array<{ + paths: string[] + events: Array<'created' | 'updated' | 'deleted'> + debounceMs?: number + match?: string + }> + handlerCode: string + inputs?: Record + memory?: { enabled: boolean; scopes?: string[]; ttlDays?: number } + harnessSettings?: { reasoning?: 'low' | 'medium' | 'high'; timeoutSeconds?: number } + mount?: { enabled: boolean } + runMode?: 'cloud' | 'local' +} + +export type PersonaSpecJson = { + id: string + name: string + description?: string + cloud: true + cloudAgentId: string + harness: ProactiveAgentDraft['harness'] + model: string + systemPrompt: string + integrations: ProactiveAgentDraft['integrations'] + watch: ProactiveAgentDraft['watch'] + onEvent: string + inputs?: Record + memory?: ProactiveAgentDraft['memory'] + harnessSettings?: ProactiveAgentDraft['harnessSettings'] + mount: { enabled: boolean } + schedules?: unknown +} + +export interface StageBundleInput { + projectId: string + draft: ProactiveAgentDraft +} + +export interface StagedBundle { + bundleDir: string + personaPath: string + handlerPath: string +} + +export interface DeployBundleInput { + projectId: string + draft: ProactiveAgentDraft + workspace: string + cloudUrl?: string + onLog?: (line: string) => void + onPhase?: (phase: ProactiveAgentDeployPhase) => void + io?: DeployIO +} + +export interface DeployBundleResult { + status: ProactiveAgentDeployStatus + error?: string + bundleDir: string + deploymentId?: string +} + +type DeployModule = { + deploy(opts: DeployOptions): Promise +} + +type CloudRunHandleStatus = 'starting' | 'active' | 'failed' | 'cancelled' + +type DeployResultWithHostedStatus = DeployResult & { + status?: ProactiveAgentDeployStatus + error?: string + runHandle?: { + status?: CloudRunHandleStatus | ProactiveAgentDeployStatus + error?: unknown + lastError?: unknown + } +} + +export function proactiveAgentBundleDir(projectId: string, personaId: string): string { + return join(pearUserDataDir(), 'proactive-agents', projectId, personaId) +} + +export function buildPersonaSpec( + draft: ProactiveAgentDraft, + opts: { handlerEntry?: string } = {} +): PersonaSpecJson { + const schedules = (draft as ProactiveAgentDraft & { schedules?: unknown }).schedules + const persona: PersonaSpecJson = { + id: draft.id, + name: draft.name, + cloud: true, + cloudAgentId: draft.cloudAgentId, + harness: draft.harness, + model: draft.model, + systemPrompt: draft.systemPrompt, + integrations: draft.integrations, + watch: draft.watch, + onEvent: opts.handlerEntry ?? './agent.ts', + mount: { enabled: draft.mount?.enabled ?? false } + } + + if (draft.description) persona.description = draft.description + if (draft.inputs) persona.inputs = draft.inputs + if (draft.memory) persona.memory = draft.memory + if (draft.harnessSettings) persona.harnessSettings = draft.harnessSettings + + // TODO(v2): cron triggers are UI-only until the cloud trigger router supports them. + if (schedules !== undefined) persona.schedules = schedules + + return persona +} + +export async function stageBundle(input: StageBundleInput): Promise { + const bundleDir = proactiveAgentBundleDir(input.projectId, input.draft.id) + const personaPath = join(bundleDir, 'persona.json') + const handlerPath = join(bundleDir, 'agent.ts') + + await mkdir(bundleDir, { recursive: true }) + await writeFile(handlerPath, input.draft.handlerCode, 'utf8') + await writeFile( + personaPath, + `${JSON.stringify(buildPersonaSpec(input.draft, { handlerEntry: './agent.ts' }), null, 2)}\n`, + 'utf8' + ) + + return { bundleDir, personaPath, handlerPath } +} + +export async function deployBundle(input: DeployBundleInput): Promise { + input.onPhase?.('validate') + input.onPhase?.('bundle') + const staged = await stageBundle({ projectId: input.projectId, draft: input.draft }) + + const deployOptions: DeployOptions = { + personaPath: staged.personaPath, + mode: 'cloud', + workspace: input.workspace, + noPrompt: true, + onExists: 'update', + inputs: input.draft.inputs, + onLog: input.onLog, + io: input.io, + ...(input.cloudUrl ? { cloudUrl: input.cloudUrl } : {}) + } + + try { + input.onPhase?.('upload') + const { deploy } = (await import('@agentworkforce/deploy')) as DeployModule + const result = (await deploy(deployOptions)) as DeployResultWithHostedStatus + input.onPhase?.('warm') + input.onPhase?.('register') + + const error = deployResultError(result) + return { + status: mapDeployStatus(result), + bundleDir: staged.bundleDir, + deploymentId: result.deploymentId, + ...(error ? { error } : {}) + } + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + input.onLog?.(error) + return { + status: 'error', + error, + bundleDir: staged.bundleDir + } + } +} + +function pearUserDataDir(): string { + const override = process.env.PEAR_CONFIG_DIR?.trim() + if (override) return override + return app.getPath('userData') +} + +export function mapDeployStatus(result: DeployResultWithHostedStatus): ProactiveAgentDeployStatus { + const handleStatus = result.runHandle?.status + if (handleStatus === 'starting' || handleStatus === 'warming') return 'warming' + // `cancelled` is part of CloudRunHandleStatus; without this branch it falls + // through to the default `'active'` below, which would silently report a + // cancelled deployment as a successful one. + if (handleStatus === 'failed' || handleStatus === 'cancelled' || handleStatus === 'error') return 'error' + if (handleStatus === 'active') return 'active' + + if (result.status === 'warming' || result.status === 'error') return result.status + return 'active' +} + +function deployResultError(result: DeployResultWithHostedStatus): string | undefined { + if (result.error) return result.error + + const handleError = result.runHandle?.error ?? result.runHandle?.lastError + if (handleError instanceof Error) return handleError.message + if (typeof handleError === 'string' && handleError.trim()) return handleError + return undefined +} diff --git a/src/main/proactive-agent.ts b/src/main/proactive-agent.ts new file mode 100644 index 00000000..a3877d04 --- /dev/null +++ b/src/main/proactive-agent.ts @@ -0,0 +1,692 @@ +import { rm } from 'node:fs/promises' +import { + buildPersonaSpec, + deployBundle, + proactiveAgentBundleDir, + stageBundle +} from './proactive-agent.bundle' +import { resolveCloudAuth } from './auth' +import { loadStore, saveStore, type Project } from './store' +import type { + ProactiveAgentBinding, + ProactiveAgentDeployPhase, + ProactiveAgentDeployResult, + ProactiveAgentDraft, + ProactiveAgentEvent, + ProactiveAgentRun, + ProactiveAgentRunsPage, + ProactiveAgentStatus, + ProactiveAgentTranscript, + ProactiveAgentWatchEventKind +} from './proactive-agent.types' + +type Listener = (event: ProactiveAgentEvent) => void +type CloudMethod = 'GET' | 'PATCH' | 'DELETE' + +type CloudAuth = { + accessToken: string + apiUrl: string +} + +type CloudPersonaRecord = { + id?: unknown + personaId?: unknown + status?: unknown + lastError?: unknown + lastFiredAt?: unknown + updatedAt?: unknown +} + +type PersonaKitModule = { + parsePersonaSpec?: (spec: unknown) => unknown +} + +const PERSONA_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const WATCH_EVENTS = new Set(['created', 'updated', 'deleted']) +const HARNESSES = new Set(['claude', 'codex', 'opencode']) +const RUN_MODES = new Set(['cloud', 'local']) +const MEMORY_SCOPES = new Set(['workspace', 'project', 'persona']) +const REASONING_LEVELS = new Set(['low', 'medium', 'high']) + +let personaKitMissingWarned = false + +function nowIso(): string { + return new Date().toISOString() +} + +function normalizeId(value: string, label: string): string { + const id = typeof value === 'string' ? value.trim() : '' + if (!id) throw new Error(`${label} is required`) + return id +} + +function cloneBinding(binding: ProactiveAgentBinding): ProactiveAgentBinding { + return JSON.parse(JSON.stringify(binding)) as ProactiveAgentBinding +} + +function cloneRun(run: ProactiveAgentRun): ProactiveAgentRun { + return JSON.parse(JSON.stringify(run)) as ProactiveAgentRun +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function cloudPath(path: string, query?: Record): string { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query || {})) { + if (value) params.set(key, value) + } + const suffix = params.toString() + return suffix ? `${path}?${suffix}` : path +} + +function buildCloudApiUrl(auth: CloudAuth, path: string): string { + return new URL(path.replace(/^\/+/, ''), `${auth.apiUrl}/`).toString() +} + +function isUnsupportedCloudEndpoint(response: Response): boolean { + return response.status === 404 || response.status === 405 || response.status === 501 +} + +function readCloudError(payload: unknown, fallback: string): string { + if (isRecord(payload)) { + for (const key of ['error', 'message', 'detail']) { + const value = payload[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + } + + return fallback +} + +async function readCloudJson(response: Response): Promise { + return response.json().catch(() => null) +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isMissingModuleError(error: unknown): boolean { + if (!isRecord(error)) return false + const code = error.code + if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') return true + const message = typeof error.message === 'string' ? error.message : '' + return message.includes('Cannot find package') || message.includes('Cannot find module') +} + +async function loadPersonaKit(): Promise { + const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise + + try { + return await dynamicImport('@agentworkforce/persona-kit') as PersonaKitModule + } catch (error) { + if (isMissingModuleError(error)) return null + throw error + } +} + +function normalizeCloudStatus(value: unknown): ProactiveAgentStatus | null { + if (value === 'draft' || value === 'warming' || value === 'active' || value === 'paused' || value === 'error') { + return value + } + if (value === 'ready') return 'active' + if (value === 'failed') return 'error' + return null +} + +function normalizeRunsPage(payload: unknown): ProactiveAgentRunsPage { + if (Array.isArray(payload)) { + return { runs: payload as ProactiveAgentRun[] } + } + + if (isRecord(payload)) { + return { + runs: Array.isArray(payload.runs) ? payload.runs as ProactiveAgentRun[] : [], + ...(typeof payload.nextCursor === 'string' ? { nextCursor: payload.nextCursor } : {}) + } + } + + return { runs: [] } +} + +function normalizeTranscript(runId: string, payload: unknown): ProactiveAgentTranscript { + if (!isRecord(payload)) return { runId, messages: [] } + const messages = Array.isArray(payload.messages) + ? payload.messages as ProactiveAgentTranscript['messages'] + : [] + + return { + runId: typeof payload.runId === 'string' ? payload.runId : runId, + ...(typeof payload.projectId === 'string' ? { projectId: payload.projectId } : {}), + ...(typeof payload.personaId === 'string' ? { personaId: payload.personaId } : {}), + messages + } +} + +function normalizeCloudPersonaList(payload: unknown): CloudPersonaRecord[] { + if (Array.isArray(payload)) return payload as CloudPersonaRecord[] + if (isRecord(payload)) { + for (const key of ['personas', 'agents', 'items']) { + const value = payload[key] + if (Array.isArray(value)) return value as CloudPersonaRecord[] + } + } + return [] +} + +export class ProactiveAgentManager { + private readonly listeners = new Set() + private readonly runCache = new Map() + + async restore(projectId: string): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const project = this.requireProject(normalizedProjectId) + let bindings = (project.proactiveAgents || []).map(cloneBinding) + + try { + const cloudRecords = await this.listCloudPersonas(project) + bindings = this.reconcileCloudBindings(normalizedProjectId, bindings, cloudRecords) + this.replaceProjectBindings(normalizedProjectId, bindings) + } catch (error) { + console.warn(`[proactive-agent] Failed to reconcile cloud status for ${normalizedProjectId}: ${toErrorMessage(error)}`) + } + + for (const binding of bindings) { + this.emit({ type: 'binding-updated', projectId: normalizedProjectId, personaId: binding.personaId, binding }) + } + } + + async list(projectId: string): Promise { + const project = this.requireProject(projectId) + return (project.proactiveAgents || []).map(cloneBinding) + } + + async create(projectId: string, draft: ProactiveAgentDraft): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + this.requireProject(normalizedProjectId) + const normalizedDraft = this.normalizeDraft(draft) + await this.validateDraft(normalizedDraft) + await stageBundle({ projectId: normalizedProjectId, draft: normalizedDraft }) + + const timestamp = nowIso() + const binding: ProactiveAgentBinding = { + projectId: normalizedProjectId, + personaId: normalizedDraft.id, + cloudAgentId: normalizedDraft.cloudAgentId, + status: 'draft', + createdAt: timestamp, + updatedAt: timestamp, + draft: normalizedDraft + } + + this.upsertBinding(normalizedProjectId, binding) + this.emit({ type: 'binding-updated', projectId: normalizedProjectId, personaId: binding.personaId, binding }) + return cloneBinding(binding) + } + + async update(projectId: string, personaId: string, draft: ProactiveAgentDraft): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const normalizedPersonaId = normalizeId(personaId, 'Persona id') + const current = this.requireBinding(normalizedProjectId, normalizedPersonaId) + const normalizedDraft = this.normalizeDraft({ ...draft, id: normalizedPersonaId }) + await this.validateDraft(normalizedDraft) + await stageBundle({ projectId: normalizedProjectId, draft: normalizedDraft }) + + const binding: ProactiveAgentBinding = { + ...current, + cloudAgentId: normalizedDraft.cloudAgentId, + status: current.status === 'error' ? 'draft' : current.status, + lastError: undefined, + updatedAt: nowIso(), + draft: normalizedDraft + } + + this.upsertBinding(normalizedProjectId, binding) + this.emit({ type: 'binding-updated', projectId: normalizedProjectId, personaId: binding.personaId, binding }) + return cloneBinding(binding) + } + + async deploy(projectId: string, personaId: string): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const normalizedPersonaId = normalizeId(personaId, 'Persona id') + const binding = this.requireBinding(normalizedProjectId, normalizedPersonaId) + const project = this.requireProject(normalizedProjectId) + + if (binding.draft.runMode === 'local') { + throw new Error('Local run mode is v2 — coming soon') + } + + await this.validateDraft(binding.draft) + this.updateBindingStatus(normalizedProjectId, normalizedPersonaId, 'warming') + const result = await deployBundle({ + projectId: normalizedProjectId, + draft: binding.draft, + workspace: project.relayWorkspaceId || normalizedProjectId, + onPhase: (phase) => { + this.emitDeployPhase(normalizedProjectId, normalizedPersonaId, phase) + } + }) + + this.updateBindingStatus( + normalizedProjectId, + normalizedPersonaId, + result.status, + result.error + ) + + return result.error + ? { status: result.status, error: result.error } + : { status: result.status } + } + + async pause(projectId: string, personaId: string): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const normalizedPersonaId = normalizeId(personaId, 'Persona id') + const binding = this.requireBinding(normalizedProjectId, normalizedPersonaId) + const project = this.requireProject(normalizedProjectId) + + await this.patchCloudPersonaStatus(project, binding, 'paused') + this.updateBindingStatus(normalizedProjectId, normalizedPersonaId, 'paused') + } + + async resume(projectId: string, personaId: string): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const normalizedPersonaId = normalizeId(personaId, 'Persona id') + const binding = this.requireBinding(normalizedProjectId, normalizedPersonaId) + const project = this.requireProject(normalizedProjectId) + + await this.patchCloudPersonaStatus(project, binding, 'active') + this.updateBindingStatus(normalizedProjectId, normalizedPersonaId, 'active') + } + + async undeploy(projectId: string, personaId: string): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const normalizedPersonaId = normalizeId(personaId, 'Persona id') + const binding = this.requireBinding(normalizedProjectId, normalizedPersonaId) + const project = this.requireProject(normalizedProjectId) + + await this.deleteCloudPersona(project, binding) + await rm(proactiveAgentBundleDir(normalizedProjectId, normalizedPersonaId), { recursive: true, force: true }) + + const data = loadStore() + const storedProject = this.findProject(data.projects, normalizedProjectId) + if (!storedProject) return + + storedProject.proactiveAgents = (storedProject.proactiveAgents || []).filter((entry) => entry.personaId !== normalizedPersonaId) + saveStore(data) + this.runCache.delete(this.runCacheKey(normalizedProjectId, normalizedPersonaId)) + this.emit({ type: 'binding-removed', projectId: normalizedProjectId, personaId: normalizedPersonaId }) + } + + async runs(projectId: string, personaId: string, opts: { limit?: number; cursor?: string } = {}): Promise { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const normalizedPersonaId = normalizeId(personaId, 'Persona id') + const binding = this.requireBinding(normalizedProjectId, normalizedPersonaId) + const project = this.requireProject(normalizedProjectId) + const page = await this.fetchCloudRuns(project, binding, opts) + + this.runCache.set(this.runCacheKey(normalizedProjectId, normalizedPersonaId), page.runs.map(cloneRun)) + return { + runs: page.runs.map(cloneRun), + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}) + } + } + + async runTranscript(runId: string): Promise { + const normalizedRunId = normalizeId(runId, 'Run id') + return this.fetchCloudTranscript(normalizedRunId) + } + + onEvent(handler: Listener): () => void { + this.listeners.add(handler) + return () => this.listeners.delete(handler) + } + + private emit(event: ProactiveAgentEvent): void { + this.listeners.forEach((listener) => { + listener(event) + }) + } + + private emitDeployPhase(projectId: string, personaId: string, phase: ProactiveAgentDeployPhase, error?: string): void { + this.emit({ + type: 'deploy-phase', + projectId, + personaId, + phase, + status: error ? 'error' : 'done', + ...(error ? { error } : {}) + } as unknown as ProactiveAgentEvent) + } + + private normalizeDraft(draft: ProactiveAgentDraft): ProactiveAgentDraft { + if (!isRecord(draft)) throw new Error('Draft is required') + const id = normalizeId(draft.id, 'Agent id') + const name = normalizeId(draft.name, 'Agent name') + const cloudAgentId = normalizeId(draft.cloudAgentId, 'Cloud agent id') + const model = normalizeId(draft.model, 'Model') + const systemPrompt = normalizeId(draft.systemPrompt, 'System prompt') + const handlerCode = normalizeId(draft.handlerCode, 'Handler code') + + return { + ...draft, + id, + name, + cloudAgentId, + model, + systemPrompt, + handlerCode, + integrations: isRecord(draft.integrations) ? draft.integrations : {}, + watch: Array.isArray(draft.watch) ? draft.watch : [], + mount: { enabled: draft.mount?.enabled ?? false }, + runMode: draft.runMode || 'cloud' + } + } + + private async validateDraft(draft: ProactiveAgentDraft): Promise { + if (!PERSONA_ID_RE.test(draft.id)) { + throw new Error('Agent id must be kebab-case') + } + if (!HARNESSES.has(draft.harness)) { + throw new Error('Harness must be one of claude, codex, or opencode') + } + if (!RUN_MODES.has(draft.runMode || 'cloud')) { + throw new Error('Run mode must be cloud or local') + } + if (!isRecord(draft.integrations)) { + throw new Error('Integrations must be an object') + } + for (const [key, value] of Object.entries(draft.integrations)) { + if (!key.trim() || !isRecord(value)) throw new Error('Each integration must be an object') + } + if (!Array.isArray(draft.watch) || draft.watch.length === 0) { + throw new Error('At least one watch rule is required') + } + draft.watch.forEach((rule, index) => { + if (!isRecord(rule)) throw new Error(`Watch rule ${index + 1} must be an object`) + if (!Array.isArray(rule.paths) || rule.paths.length === 0) { + throw new Error(`Watch rule ${index + 1} requires at least one path`) + } + for (const path of rule.paths) { + if (typeof path !== 'string' || !path.trim()) throw new Error(`Watch rule ${index + 1} includes an empty path`) + } + if (!Array.isArray(rule.events) || rule.events.length === 0) { + throw new Error(`Watch rule ${index + 1} requires at least one event`) + } + for (const event of rule.events) { + if (!WATCH_EVENTS.has(event)) throw new Error(`Watch rule ${index + 1} has an unsupported event`) + } + if (rule.debounceMs !== undefined && (!Number.isFinite(rule.debounceMs) || rule.debounceMs < 0)) { + throw new Error(`Watch rule ${index + 1} debounce must be a non-negative number`) + } + if (rule.match !== undefined && typeof rule.match !== 'string') { + throw new Error(`Watch rule ${index + 1} match must be a string`) + } + }) + if (draft.inputs !== undefined) { + if (!isRecord(draft.inputs)) throw new Error('Inputs must be an object') + for (const [key, value] of Object.entries(draft.inputs)) { + if (!key.trim() || typeof value !== 'string') throw new Error('Inputs must be string key/value pairs') + } + } + if (draft.memory !== undefined) { + if (!isRecord(draft.memory) || typeof draft.memory.enabled !== 'boolean') { + throw new Error('Memory enabled must be a boolean') + } + if (draft.memory.scopes !== undefined) { + if (!Array.isArray(draft.memory.scopes)) throw new Error('Memory scopes must be an array') + for (const scope of draft.memory.scopes) { + if (!MEMORY_SCOPES.has(scope)) throw new Error('Memory scope is unsupported') + } + } + if (draft.memory.ttlDays !== undefined && (!Number.isFinite(draft.memory.ttlDays) || draft.memory.ttlDays <= 0)) { + throw new Error('Memory TTL must be a positive number') + } + } + if (draft.harnessSettings !== undefined) { + if (!isRecord(draft.harnessSettings)) throw new Error('Harness settings must be an object') + if (draft.harnessSettings.reasoning !== undefined && !REASONING_LEVELS.has(draft.harnessSettings.reasoning)) { + throw new Error('Harness reasoning level is unsupported') + } + if ( + draft.harnessSettings.timeoutSeconds !== undefined && + (!Number.isFinite(draft.harnessSettings.timeoutSeconds) || draft.harnessSettings.timeoutSeconds <= 0) + ) { + throw new Error('Harness timeout must be a positive number') + } + } + if (draft.mount !== undefined && (!isRecord(draft.mount) || typeof draft.mount.enabled !== 'boolean')) { + throw new Error('Mount enabled must be a boolean') + } + + const personaKit = await loadPersonaKit() + if (!personaKit?.parsePersonaSpec) { + if (!personaKitMissingWarned) { + console.warn('[proactive-agent] @agentworkforce/persona-kit is unavailable; using Pear runtime validation only.') + personaKitMissingWarned = true + } + return + } + + try { + personaKit.parsePersonaSpec(buildPersonaSpec(draft, { handlerEntry: './agent.ts' })) + } catch (error) { + throw new Error(`Persona validation failed: ${toErrorMessage(error)}`) + } + } + + private updateBindingStatus( + projectId: string, + personaId: string, + status: ProactiveAgentBinding['status'], + lastError?: string + ): ProactiveAgentBinding { + const normalizedProjectId = normalizeId(projectId, 'Project id') + const normalizedPersonaId = normalizeId(personaId, 'Persona id') + const current = this.requireBinding(normalizedProjectId, normalizedPersonaId) + const binding: ProactiveAgentBinding = { + ...current, + status, + ...(lastError ? { lastError } : { lastError: undefined }), + updatedAt: nowIso() + } + this.upsertBinding(normalizedProjectId, binding) + this.emit({ type: 'binding-updated', projectId: normalizedProjectId, personaId: normalizedPersonaId, binding }) + return binding + } + + private upsertBinding(projectId: string, binding: ProactiveAgentBinding): void { + const data = loadStore() + const project = this.findProject(data.projects, projectId) + if (!project) throw new Error('Project not found') + + const bindings = project.proactiveAgents || [] + const index = bindings.findIndex((candidate) => candidate.personaId === binding.personaId) + if (index === -1) { + bindings.push(binding) + } else { + bindings[index] = binding + } + project.proactiveAgents = bindings + saveStore(data) + } + + private replaceProjectBindings(projectId: string, bindings: ProactiveAgentBinding[]): void { + const data = loadStore() + const project = this.findProject(data.projects, projectId) + if (!project) throw new Error('Project not found') + project.proactiveAgents = bindings.map(cloneBinding) + saveStore(data) + } + + private requireBinding(projectId: string, personaId: string): ProactiveAgentBinding { + const project = this.requireProject(projectId) + const binding = (project.proactiveAgents || []).find((candidate) => candidate.personaId === personaId) + if (!binding) throw new Error('Proactive agent binding not found') + return cloneBinding(binding) + } + + private requireProject(projectId: string): Project { + const project = this.findProject(loadStore().projects, normalizeId(projectId, 'Project id')) + if (!project) throw new Error('Project not found') + return project + } + + private findProject(projects: Project[], projectId: string): Project | undefined { + return projects.find((project) => project.id === projectId) + } + + private runCacheKey(projectId: string, personaId: string): string { + return `${projectId}:${personaId}` + } + + private async requireCloudAuth(): Promise { + const auth = await resolveCloudAuth() + if (!auth) throw new Error('Cloud login is required') + return auth + } + + private async requestCloudApi(method: CloudMethod, endpoints: string[], body?: unknown): Promise { + const auth = await this.requireCloudAuth() + let lastUnsupported: Error | null = null + + for (const endpoint of endpoints) { + const response = await fetch(buildCloudApiUrl(auth, endpoint), { + method, + headers: { + Authorization: `Bearer ${auth.accessToken}`, + 'Content-Type': 'application/json' + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }) + const payload = await readCloudJson(response) + + if (isUnsupportedCloudEndpoint(response)) { + lastUnsupported = new Error(`${method} ${endpoint} is not supported: ${response.status} ${readCloudError(payload, response.statusText)}`) + continue + } + + if (!response.ok) { + throw new Error(`${method} ${endpoint} failed: ${response.status} ${readCloudError(payload, response.statusText)}`) + } + + return payload + } + + throw lastUnsupported || new Error(`${method} cloud request is not supported by the configured cloud API`) + } + + private workspaceId(project: Project): string { + return project.relayWorkspaceId || project.id + } + + private personaEndpoints(project: Project, personaId: string): string[] { + const workspace = encodeURIComponent(this.workspaceId(project)) + const persona = encodeURIComponent(personaId) + return [ + `/api/v1/workspaces/${workspace}/proactive-personas/${persona}`, + cloudPath(`/api/v1/proactive-personas/${persona}`, { + workspace: this.workspaceId(project), + projectId: project.id + }) + ] + } + + private async listCloudPersonas(project: Project): Promise { + const workspace = encodeURIComponent(this.workspaceId(project)) + const payload = await this.requestCloudApi('GET', [ + cloudPath(`/api/v1/workspaces/${workspace}/proactive-personas`, { projectId: project.id }), + cloudPath('/api/v1/proactive-personas', { + workspace: this.workspaceId(project), + projectId: project.id + }) + ]) + return normalizeCloudPersonaList(payload) + } + + private reconcileCloudBindings( + projectId: string, + bindings: ProactiveAgentBinding[], + records: CloudPersonaRecord[] + ): ProactiveAgentBinding[] { + const byId = new Map() + for (const record of records) { + const id = typeof record.personaId === 'string' + ? record.personaId + : typeof record.id === 'string' + ? record.id + : '' + if (id) byId.set(id, record) + } + + return bindings.map((binding) => { + const record = byId.get(binding.personaId) + if (!record) return cloneBinding(binding) + + const status = normalizeCloudStatus(record.status) + return { + ...binding, + projectId, + ...(status ? { status } : {}), + ...(typeof record.lastError === 'string' && record.lastError.trim() + ? { lastError: record.lastError } + : { lastError: undefined }), + ...(typeof record.lastFiredAt === 'string' && record.lastFiredAt.trim() + ? { lastFiredAt: record.lastFiredAt } + : {}), + ...(typeof record.updatedAt === 'string' && record.updatedAt.trim() + ? { updatedAt: record.updatedAt } + : { updatedAt: nowIso() }) + } + }) + } + + private async patchCloudPersonaStatus( + project: Project, + binding: ProactiveAgentBinding, + status: 'active' | 'paused' + ): Promise { + await this.requestCloudApi('PATCH', this.personaEndpoints(project, binding.personaId), { + status, + projectId: project.id, + workspace: this.workspaceId(project) + }) + } + + private async deleteCloudPersona(project: Project, binding: ProactiveAgentBinding): Promise { + await this.requestCloudApi('DELETE', this.personaEndpoints(project, binding.personaId)) + } + + private async fetchCloudRuns( + project: Project, + binding: ProactiveAgentBinding, + opts: { limit?: number; cursor?: string } + ): Promise { + const workspace = encodeURIComponent(this.workspaceId(project)) + const persona = encodeURIComponent(binding.personaId) + const query = { + projectId: project.id, + workspace: this.workspaceId(project), + limit: opts.limit && opts.limit > 0 ? String(opts.limit) : undefined, + cursor: opts.cursor + } + const payload = await this.requestCloudApi('GET', [ + cloudPath(`/api/v1/workspaces/${workspace}/proactive-personas/${persona}/runs`, query), + cloudPath(`/api/v1/proactive-personas/${persona}/runs`, query) + ]) + return normalizeRunsPage(payload) + } + + private async fetchCloudTranscript(runId: string): Promise { + const run = encodeURIComponent(runId) + const payload = await this.requestCloudApi('GET', [ + `/api/v1/proactive-runs/${run}/transcript`, + `/api/v1/proactive/runs/${run}/transcript` + ]) + return normalizeTranscript(runId, payload) + } +} + +export const proactiveAgentManager = new ProactiveAgentManager() diff --git a/src/main/proactive-agent.types.ts b/src/main/proactive-agent.types.ts new file mode 100644 index 00000000..13b683b5 --- /dev/null +++ b/src/main/proactive-agent.types.ts @@ -0,0 +1,106 @@ +export type ProactiveAgentHarness = 'claude' | 'codex' | 'opencode' +export type ProactiveAgentStatus = 'draft' | 'warming' | 'active' | 'paused' | 'error' +export type ProactiveAgentRunStatus = 'running' | 'succeeded' | 'failed' +export type ProactiveAgentRunMode = 'cloud' | 'local' +export type ProactiveAgentWatchEventKind = 'created' | 'updated' | 'deleted' +export type ProactiveAgentDeployPhase = 'validate' | 'bundle' | 'upload' | 'warm' | 'register' + +export type ProactiveAgentWatchRule = { + paths: string[] + events: ProactiveAgentWatchEventKind[] + debounceMs?: number + match?: string +} + +export type ProactiveAgentMemoryConfig = { + enabled: boolean + scopes?: Array<'workspace' | 'project' | 'persona'> + ttlDays?: number +} + +export type ProactiveAgentHarnessSettings = { + reasoning?: 'low' | 'medium' | 'high' + timeoutSeconds?: number +} + +export type ProactiveAgentMountConfig = { + enabled: boolean +} + +export type ProactiveAgentDraft = { + id: string + name: string + description?: string + cloudAgentId: string + harness: ProactiveAgentHarness + model: string + systemPrompt: string + integrations: Record> + watch: ProactiveAgentWatchRule[] + handlerCode: string + inputs?: Record + memory?: ProactiveAgentMemoryConfig + harnessSettings?: ProactiveAgentHarnessSettings + mount?: ProactiveAgentMountConfig + runMode?: ProactiveAgentRunMode +} + +export type ProactiveAgentBinding = { + projectId: string + personaId: string + cloudAgentId: string + status: ProactiveAgentStatus + lastError?: string + lastFiredAt?: string + createdAt: string + updatedAt: string + draft: ProactiveAgentDraft +} + +export type ProactiveAgentRunTrigger = { + type: 'relayfile-change' + path: string + eventKind: ProactiveAgentWatchEventKind +} + +export type ProactiveAgentRun = { + runId: string + projectId: string + personaId: string + firedAt: string + trigger: ProactiveAgentRunTrigger + durationMs?: number + status: ProactiveAgentRunStatus + summary?: string + error?: string +} + +export type ProactiveAgentTranscriptMessage = { + role: 'system' | 'user' | 'assistant' | 'tool' + content: string + ts: string +} + +export type ProactiveAgentTranscript = { + runId: string + projectId?: string + personaId?: string + messages: ProactiveAgentTranscriptMessage[] +} + +export type ProactiveAgentRunsPage = { + runs: ProactiveAgentRun[] + nextCursor?: string +} + +export type ProactiveAgentDeployResult = { + status: 'active' | 'warming' | 'error' + error?: string +} + +export type ProactiveAgentEvent = + | { type: 'binding-updated'; projectId: string; personaId: string; binding: ProactiveAgentBinding } + | { type: 'binding-removed'; projectId: string; personaId: string } + | { type: 'run-started'; projectId: string; personaId: string; run: ProactiveAgentRun } + | { type: 'run-update'; projectId: string; personaId: string; runId: string; chunk: string } + | { type: 'run-finished'; projectId: string; personaId: string; run: ProactiveAgentRun } diff --git a/src/renderer/src/components/proactive/ProactiveAgentCard.tsx b/src/renderer/src/components/proactive/ProactiveAgentCard.tsx new file mode 100644 index 00000000..fa6ab6e4 --- /dev/null +++ b/src/renderer/src/components/proactive/ProactiveAgentCard.tsx @@ -0,0 +1,302 @@ +import type React from 'react' +import { useState } from 'react' +import { + Edit3, + Loader2, + Megaphone, + MoreHorizontal, + Pause, + Play, + RadioTower, + Trash2 +} from 'lucide-react' + +// TODO(sibling): import ProactiveAgentBinding from '@/lib/ipc' once the IPC slice exports it. +export type ProactiveAgentDraft = { + id: string + name: string + description?: string + cloudAgentId: string + harness: 'claude' | 'codex' | 'opencode' + model: string + systemPrompt: string + integrations: Record> + watch: Array<{ + paths: string[] + events: Array<'created' | 'updated' | 'deleted'> + debounceMs?: number + match?: string + }> + handlerCode: string + inputs?: Record + memory?: { enabled: boolean; scopes?: string[]; ttlDays?: number } + harnessSettings?: { reasoning?: 'low' | 'medium' | 'high'; timeoutSeconds?: number } + mount?: { enabled: boolean } + runMode?: 'cloud' | 'local' +} + +export type ProactiveAgentBinding = { + projectId: string + personaId: string + cloudAgentId: string + status: 'draft' | 'warming' | 'active' | 'paused' | 'error' + lastError?: string + lastFiredAt?: string + createdAt: string + updatedAt: string + draft: ProactiveAgentDraft +} + +export type ProactiveAgentCardProps = { + binding: ProactiveAgentBinding + onEdit: (personaId: string) => void + onViewRuns: (personaId: string) => void + onPause: (personaId: string) => void | Promise + onResume: (personaId: string) => void | Promise + onUndeploy: (personaId: string) => void | Promise +} + +type StatusMeta = { + label: string + dotClassName: string + pillClassName: string +} + +const STATUS_META: Record = { + active: { + label: 'Active', + dotClassName: 'bg-[var(--pear-green)]', + pillClassName: 'border-[var(--pear-green)]/25 bg-[var(--pear-green)]/10 text-[var(--pear-green)]' + }, + warming: { + label: 'Warming', + dotClassName: 'animate-pulse bg-[var(--pear-yellow)]', + pillClassName: 'border-[var(--pear-yellow)]/25 bg-[var(--pear-yellow)]/10 text-[var(--pear-yellow)]' + }, + paused: { + label: 'Paused', + dotClassName: 'bg-[var(--pear-text-faint)]', + pillClassName: 'border-[var(--pear-border-subtle)] bg-[var(--pear-bg-overlay)] text-[var(--pear-text-faint)]' + }, + error: { + label: 'Error', + dotClassName: 'bg-[var(--pear-red)]', + pillClassName: 'border-[var(--pear-red)]/25 bg-[var(--pear-red)]/10 text-[var(--pear-red)]' + }, + draft: { + label: 'Draft', + dotClassName: 'bg-[var(--pear-text-dim)]', + pillClassName: 'border-[var(--pear-border-subtle)] bg-[var(--pear-bg-overlay)] text-[var(--pear-text-dim)]' + } +} + +function formatRelativeTime(value: string | undefined): string { + if (!value) return 'never' + + const timestamp = new Date(value).getTime() + if (!Number.isFinite(timestamp)) return 'recently' + + const diffMs = Date.now() - timestamp + if (diffMs < 60_000) return 'just now' + + const minutes = Math.floor(diffMs / 60_000) + if (minutes < 60) return `${minutes}m ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + + const years = Math.floor(months / 12) + return `${years}y ago` +} + +function truncateMiddle(value: string, maxLength = 72): string { + if (value.length <= maxLength) return value + + const edgeLength = Math.floor((maxLength - 3) / 2) + return `${value.slice(0, edgeLength)}...${value.slice(value.length - edgeLength)}` +} + +function getWatchPaths(binding: ProactiveAgentBinding): string[] { + return binding.draft.watch.flatMap((rule) => rule.paths).filter((path) => path.trim().length > 0) +} + +function getAgentName(binding: ProactiveAgentBinding): string { + return binding.draft.name.trim() || binding.draft.id || binding.personaId +} + +function ActionButton({ + children, + disabled, + onClick, + title +}: { + children: React.ReactNode + disabled: boolean + onClick: () => void + title: string +}): React.ReactNode { + return ( + + ) +} + +export function ProactiveAgentCard({ + binding, + onEdit, + onViewRuns, + onPause, + onResume, + onUndeploy +}: ProactiveAgentCardProps): React.ReactNode { + const [busy, setBusy] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const status = STATUS_META[binding.status] + const agentName = getAgentName(binding) + const watchPaths = getWatchPaths(binding) + const firstWatchPath = watchPaths[0] + const remainingWatchCount = Math.max(0, watchPaths.length - 1) + const shouldResume = binding.status === 'paused' + + async function runAction(action: () => void | Promise): Promise { + if (busy) return + + setBusy(true) + try { + await action() + } finally { + setBusy(false) + } + } + + async function handleDelete(): Promise { + setMenuOpen(false) + if (!window.confirm(`Delete agent "${agentName}"?`)) return + await runAction(() => onUndeploy(binding.personaId)) + } + + return ( +
+
+
+
+ + + + +
+
+

{agentName}

+ + {status.label} + + + {binding.draft.runMode || 'cloud'} + +
+ +
+ + {firstWatchPath ? ( + + Trigger: {truncateMiddle(firstWatchPath)} + {remainingWatchCount > 0 && ( + +{remainingWatchCount} more + )} + + ) : ( + No triggers configured + )} +
+ +
+ Last fired: {formatRelativeTime(binding.lastFiredAt)} +
+ + {binding.lastError && ( +
+ {binding.lastError} +
+ )} +
+
+
+ +
+ {busy && } + + void runAction(() => onViewRuns(binding.personaId))} + title={`View runs for ${agentName}`} + > + + View runs + + + void runAction(() => onEdit(binding.personaId))} + title={`Edit ${agentName}`} + > + + Edit + + + void runAction(() => shouldResume ? onResume(binding.personaId) : onPause(binding.personaId))} + title={`${shouldResume ? 'Resume' : 'Pause'} ${agentName}`} + > + {shouldResume ? : } + {shouldResume ? 'Resume' : 'Pause'} + + + + + {menuOpen && ( +
+ +
+ )} +
+
+
+ ) +} + +export default ProactiveAgentCard diff --git a/src/renderer/src/components/proactive/ProactiveAgentEditor.tsx b/src/renderer/src/components/proactive/ProactiveAgentEditor.tsx new file mode 100644 index 00000000..9508707c --- /dev/null +++ b/src/renderer/src/components/proactive/ProactiveAgentEditor.tsx @@ -0,0 +1,1179 @@ +import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import { + AlertTriangle, + CheckCircle2, + Cloud, + Code2, + Database, + Eye, + EyeOff, + Info, + Loader2, + Plus, + RadioTower, + Save, + Send, + Settings, + Trash2, + X +} from 'lucide-react' +import { pear, type CloudAgentRecord, type ConnectedIntegration, type ProactiveAgentBinding, type ProactiveAgentDraft } from '@/lib/ipc' +import { useCloudAgentCatalog } from '@/hooks/use-cloud-agent' +import { useProactiveAgents } from '@/hooks/use-proactive-agent' +import { useProjectStore, type ProjectIntegration } from '@/stores/project-store' + +export type ProactiveAgentEditorProps = { + projectId: string + personaId?: string + onClose: () => void +} + +type Harness = ProactiveAgentDraft['harness'] +type WatchRule = ProactiveAgentDraft['watch'][number] +type MemoryScope = NonNullable['scopes']>[number] +type DeployPhaseId = 'validate' | 'bundle' | 'upload' | 'warm' | 'register' +type DeployPhaseStatus = 'pending' | 'active' | 'done' | 'error' + +type DeployPhase = { + id: DeployPhaseId + label: string + status: DeployPhaseStatus + startedAt?: number + finishedAt?: number +} + +type ProactiveAgentDeployResult = { + status: 'active' | 'warming' | 'error' + error?: string +} + +type ProactiveAgentEvent = { + projectId?: string + personaId?: string + phase?: DeployPhaseId | 'warm-box' | 'register-triggers' + status?: string + error?: string + message?: string +} + +type ProactiveAgentIPC = { + list?: (projectId: string) => Promise + create?: (projectId: string, draft: ProactiveAgentDraft) => Promise + update?: (projectId: string, personaId: string, draft: ProactiveAgentDraft) => Promise + deploy?: (projectId: string, personaId: string) => Promise + onEvent?: (callback: (event: ProactiveAgentEvent) => void) => () => void +} + +type PearWithProactiveAgent = typeof pear & { + proactiveAgent?: ProactiveAgentIPC +} + +type ValidationErrors = Partial> + +type KeyValueRow = { + id: string + key: string + value: string +} + +const MODEL_OPTIONS: Record = { + claude: ['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'], + codex: ['gpt-5.2', 'gpt-5.1-codex', 'gpt-5.1-codex-mini'], + opencode: ['claude-sonnet-4-6', 'gpt-5.2', 'qwen3-coder'] +} + +const DEPLOY_PHASES: DeployPhase[] = [ + { id: 'validate', label: 'Validate', status: 'pending' }, + { id: 'bundle', label: 'Bundle', status: 'pending' }, + { id: 'upload', label: 'Upload', status: 'pending' }, + { id: 'warm', label: 'Warm box', status: 'pending' }, + { id: 'register', label: 'Register triggers', status: 'pending' } +] + +const DEFAULT_HANDLER_CODE = `import { handler } from '@agentworkforce/runtime'; + +export default handler(async (ctx, event) => { + if (event.type !== 'relayfile.changed') return; + const file = await ctx.fs.read(event.path); + // your logic here + await ctx.notify({ channel: 'pear', text: \`Reacted to \${event.path}\` }); +});` + +const DEFAULT_WATCH_RULE: WatchRule = { + paths: [''], + events: ['created', 'updated', 'deleted'], + debounceMs: 5000, + match: '' +} + +function createDefaultDraft(): ProactiveAgentDraft { + return { + id: '', + name: '', + description: '', + cloudAgentId: '', + harness: 'claude', + model: MODEL_OPTIONS.claude[0], + systemPrompt: 'You are a proactive agent. Use $VAR inputs when needed and act only when a trigger is relevant.', + integrations: {}, + mount: { enabled: false }, + watch: [{ ...DEFAULT_WATCH_RULE, paths: [...DEFAULT_WATCH_RULE.paths], events: [...DEFAULT_WATCH_RULE.events] }], + handlerCode: DEFAULT_HANDLER_CODE, + inputs: {}, + memory: { enabled: false, scopes: [], ttlDays: 30 }, + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + runMode: 'cloud' + } +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + +function kebabCase(value: string): string { + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +function cloneWatch(rule: WatchRule): WatchRule { + return { + paths: [...rule.paths], + events: [...rule.events], + debounceMs: rule.debounceMs, + match: rule.match || '' + } +} + +function normalizeDraft(draft: ProactiveAgentDraft): ProactiveAgentDraft { + return { + ...draft, + id: draft.id.trim(), + name: draft.name.trim(), + description: draft.description?.trim() || undefined, + cloudAgentId: draft.cloudAgentId.trim(), + model: draft.model.trim(), + systemPrompt: draft.systemPrompt.trim(), + integrations: draft.integrations || {}, + mount: { enabled: draft.mount?.enabled === true }, + watch: draft.watch.map((rule) => ({ + paths: rule.paths.map((path) => path.trim()).filter(Boolean), + events: rule.events.length > 0 ? rule.events : ['created', 'updated', 'deleted'], + debounceMs: Number.isFinite(rule.debounceMs) ? rule.debounceMs : 5000, + match: rule.match?.trim() || undefined + })), + handlerCode: draft.handlerCode, + inputs: Object.fromEntries( + Object.entries(draft.inputs || {}) + .map(([key, value]) => [key.trim(), value]) + .filter(([key]) => key.length > 0) + ), + memory: draft.memory?.enabled + ? { + enabled: true, + scopes: draft.memory.scopes || [], + ttlDays: draft.memory.ttlDays + } + : { enabled: false }, + harnessSettings: draft.harnessSettings, + runMode: 'cloud' + } +} + +function validateDraft(draft: ProactiveAgentDraft): ValidationErrors { + const normalized = normalizeDraft(draft) + const errors: ValidationErrors = {} + + if (!normalized.id) errors.id = 'Agent id is required.' + if (normalized.id && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized.id)) { + errors.id = 'Use kebab-case: lowercase letters, numbers, and single dashes.' + } + if (!normalized.name) errors.name = 'Name is required.' + if (!normalized.cloudAgentId) errors.cloudAgentId = 'Choose a cloud agent.' + if (!normalized.harness) errors.harness = 'Choose a harness.' + if (!normalized.model) errors.model = 'Choose a model.' + if (!normalized.systemPrompt) errors.systemPrompt = 'System prompt is required.' + if (!normalized.handlerCode.trim()) errors.handlerCode = 'Handler code is required.' + if (normalized.watch.length === 0 || normalized.watch.every((rule) => rule.paths.length === 0)) { + errors.watch = 'Add at least one trigger path.' + } + + return errors +} + +function hasErrors(errors: ValidationErrors): boolean { + return Object.values(errors).some(Boolean) +} + +function phaseElapsed(phase: DeployPhase): string { + if (!phase.startedAt) return '--' + + const end = phase.finishedAt || Date.now() + return `${Math.max(0, Math.round((end - phase.startedAt) / 100) / 10).toFixed(1)}s` +} + +function createKeyValueRows(values: Record | undefined): KeyValueRow[] { + const entries = Object.entries(values || {}) + if (entries.length === 0) return [{ id: crypto.randomUUID(), key: '', value: '' }] + return entries.map(([key, value]) => ({ id: crypto.randomUUID(), key, value })) +} + +function keyValueRowsToRecord(rows: KeyValueRow[]): Record { + return Object.fromEntries( + rows + .map((row) => [row.key.trim(), row.value] as const) + .filter(([key]) => key.length > 0) + ) +} + +function normalizePhaseId(phase: unknown): DeployPhaseId | null { + if (phase === 'warm-box') return 'warm' + if (phase === 'register-triggers') return 'register' + if (phase === 'validate' || phase === 'bundle' || phase === 'upload' || phase === 'warm' || phase === 'register') { + return phase + } + return null +} + +function extractFieldErrors(error: unknown): ValidationErrors { + if (!error || typeof error !== 'object') return {} + + const record = error as { fieldErrors?: unknown; errors?: unknown } + const source = record.fieldErrors || record.errors + if (!source || typeof source !== 'object') return {} + + const next: ValidationErrors = {} + for (const [key, value] of Object.entries(source as Record)) { + if (typeof value === 'string') { + next[key as keyof ValidationErrors] = value + } else if (Array.isArray(value) && typeof value[0] === 'string') { + next[key as keyof ValidationErrors] = value[0] + } + } + return next +} + +function FieldError({ message }: { message?: string }): React.ReactNode { + if (!message) return null + return
{message}
+} + +function Section({ + children, + icon, + subtitle, + title +}: { + children: React.ReactNode + icon: React.ReactNode + subtitle?: string + title: string +}): React.ReactNode { + return ( +
+ + + {icon} + + + {title} + {subtitle && {subtitle}} + + +
+ {children} +
+
+ ) +} + +function TextInput({ + error, + label, + onChange, + placeholder, + value +}: { + error?: string + label: string + onChange: (value: string) => void + placeholder?: string + value: string +}): React.ReactNode { + return ( + + ) +} + +function InlineHandlerCodeEditor({ + onChange, + value +}: { + value: string + onChange: (value: string) => void +}): React.ReactNode { + return ( +