Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/main/proactive-agent.bundle.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>
watch: Array<{
paths: string[]
events: Array<'created' | 'updated' | 'deleted'>
debounceMs?: number
match?: string
}>
handlerCode: string
inputs?: Record<string, string>
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<string, string>
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<DeployResult>
}

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<StagedBundle> {
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<DeployBundleResult> {
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'
}
Comment on lines +194 to +205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 mapDeployStatus maps cancelled run handle status to active instead of error

The CloudRunHandleStatus type explicitly includes 'cancelled' (proactive-agent.bundle.ts:84), but mapDeployStatus has no branch handling it. When handleStatus === 'cancelled', none of the three status checks match, so execution falls through to the default return 'active' at line 201. This means a cancelled deployment is reported as successfully active. The deployResultError function (proactive-agent.bundle.ts:204-211) independently may or may not find an error message, leading to either a silent false success or a contradictory { status: 'active', error: '...' } result returned to the caller at proactive-agent.ts:272-281.

Suggested change
export function mapDeployStatus(result: DeployResultWithHostedStatus): ProactiveAgentDeployStatus {
const handleStatus = result.runHandle?.status
if (handleStatus === 'starting' || handleStatus === 'warming') return 'warming'
if (handleStatus === 'failed' || handleStatus === 'error') return 'error'
if (handleStatus === 'active') return 'active'
if (result.status === 'warming' || result.status === 'error') return result.status
return 'active'
}
export function mapDeployStatus(result: DeployResultWithHostedStatus): ProactiveAgentDeployStatus {
const handleStatus = result.runHandle?.status
if (handleStatus === 'starting' || handleStatus === 'warming') return 'warming'
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'
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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
}
Loading