From 46f5e7d3003e1dbf182feee90835ad4e65568648 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:26:08 +0000 Subject: [PATCH 01/17] fix(ai): Resolve issue #2071 - Implement Goals as a thin native /goal task mode Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- packages/api/routes/dockerRoutes.ts | 4 +- packages/api/routes/goalRoutes.ts | 376 ++++++++++++++++++ packages/api/routes/index.ts | 1 + packages/api/routes/llmLogsRoutes.ts | 28 +- packages/api/routes/taskHelpers.ts | 1 + packages/api/server.ts | 6 + packages/api/services/socketSubscriptions.ts | 14 +- packages/api/services/taskWatcher.ts | 2 +- packages/api/services/taskWatcherLookup.ts | 20 +- packages/api/test/goalRoutes.test.ts | 115 ++++++ packages/core/src/agents/AgentRegistry.ts | 52 +++ packages/core/src/agents/goalCapabilities.ts | 21 + .../core/src/agents/impl/AntigravityAgent.ts | 17 +- packages/core/src/agents/impl/ClaudeAgent.ts | 8 +- packages/core/src/agents/impl/CodexAgent.ts | 8 +- .../core/src/agents/impl/OpenCodeAgent.ts | 1 + packages/core/src/agents/impl/VibeAgent.ts | 1 + .../impl/utils/codexDockerArgsBuilder.ts | 18 +- .../agents/impl/utils/dockerArgsBuilder.ts | 19 +- packages/core/src/agents/types.ts | 14 + .../core/src/claude/docker/dockerExecutor.ts | 40 +- .../migrations/20260902000000_create_goals.js | 47 +++ packages/core/src/goals.ts | 27 ++ packages/core/src/index.ts | 5 + packages/core/src/queue/taskQueue.ts | 10 +- packages/core/src/queue/taskQueue.types.ts | 16 +- propr-ui/src/App.tsx | 3 + propr-ui/src/api/goals.ts | 64 +++ propr-ui/src/components/Layout.tsx | 3 +- .../src/components/MobileBottomNavigation.tsx | 4 +- propr-ui/src/pages/GoalsPage.test.tsx | 109 +++++ propr-ui/src/pages/GoalsPage.tsx | 192 +++++++++ src/daemon.ts | 4 + src/goalRecovery.ts | 85 ++++ src/jobs/processGoalJob.ts | 283 +++++++++++++ src/worker.ts | 7 +- src/workerFactory.ts | 6 +- test/goalExecutionMode.test.ts | 79 ++++ test/goalMigration.test.ts | 40 ++ test/goalRecovery.test.ts | 51 +++ test/partialExecution.test.ts | 11 + test/worker.test.ts | 2 + 42 files changed, 1762 insertions(+), 52 deletions(-) create mode 100644 packages/api/routes/goalRoutes.ts create mode 100644 packages/api/test/goalRoutes.test.ts create mode 100644 packages/core/src/agents/goalCapabilities.ts create mode 100644 packages/core/src/db/migrations/20260902000000_create_goals.js create mode 100644 packages/core/src/goals.ts create mode 100644 propr-ui/src/api/goals.ts create mode 100644 propr-ui/src/pages/GoalsPage.test.tsx create mode 100644 propr-ui/src/pages/GoalsPage.tsx create mode 100644 src/goalRecovery.ts create mode 100644 src/jobs/processGoalJob.ts create mode 100644 test/goalExecutionMode.test.ts create mode 100644 test/goalMigration.test.ts create mode 100644 test/goalRecovery.test.ts diff --git a/packages/api/routes/dockerRoutes.ts b/packages/api/routes/dockerRoutes.ts index 2a0bccf0e..9a8f4b020 100644 --- a/packages/api/routes/dockerRoutes.ts +++ b/packages/api/routes/dockerRoutes.ts @@ -385,7 +385,7 @@ export async function stopTaskExecution(taskIdOrJobId: string, options: StopTask * Returns the container ID when the container was stopped, null otherwise. */ async function stopRunningTaskContainer(taskId: string, state: TaskState, options: StopTaskExecutionOptions): Promise { - const entry = state.history.find(h => h.state === 'claude_execution' && h.metadata?.containerId); + const entry = state.history.findLast(h => h.state === 'claude_execution' && h.metadata?.containerId); const containerId = entry?.metadata?.containerId; if (!containerId) { console.log(`[stop-execution] No container ID found for task ${taskId}, relying on abort signal`); @@ -425,7 +425,7 @@ export function createDockerRoutes(deps: DockerRoutesDeps) { return; } const state = JSON.parse(stateData) as { history: Array<{ state: string; metadata?: { containerId?: string; containerName?: string } }> }; - const entry = state.history.find(h => h.state === 'claude_execution' && h.metadata?.containerId); + const entry = state.history.findLast(h => h.state === 'claude_execution' && h.metadata?.containerId); if (!entry?.metadata?.containerId) { res.status(404).json({ error: 'No Docker container info available for this task' }); return; diff --git a/packages/api/routes/goalRoutes.ts b/packages/api/routes/goalRoutes.ts new file mode 100644 index 000000000..ed602e517 --- /dev/null +++ b/packages/api/routes/goalRoutes.ts @@ -0,0 +1,376 @@ +import { randomUUID } from 'node:crypto'; +import type { Request, Response } from 'express'; +import type { Knex } from 'knex'; +import type { Queue } from 'bullmq'; +import { + AgentRegistry, + GOAL_CONTINUE_INPUT, + buildNativeGoalCommand, + getAuthenticatedOctokit, + goalJobId, + type GoalCapability, + type GoalJobData, +} from '@propr/core'; +import type { RedisClientType } from 'redis'; +import { stopTaskExecution, type StopTaskExecutionResult } from './dockerRoutes.js'; + +interface GoalRoutesDeps { + db: Knex; + taskQueue: Queue; + redisClient: RedisClientType; + getCapabilities?: () => Promise; + stopExecution?: (taskId: string, options: Parameters[1]) => Promise; +} + +interface GoalRow { + goal_id: string; + owner_id: string; + owner_login: string; + repository: string; + objective: string; + base_branch: string | null; + branch_name: string | null; + worktree_path: string | null; + agent_id: string; + agent_alias: string; + agent_type: string; + requested_model: string; + effective_model: string | null; + max_parallel_tasks: number | null; + ultrafix: number | boolean | null; + desired_state: 'running' | 'paused' | 'cancelled'; + result_state: 'completed' | 'failed' | 'cancelled' | null; + current_task_id: string; + session_id: string | null; + conversation_id: string | null; + run_generation: number; + final_pr_number: number | null; + final_pr_url: string | null; + artifact_refs: string | unknown[] | null; + created_at: string; + updated_at: string; + started_at: string | null; + paused_at: string | null; + paused_ms: number; + completed_at: string | null; +} + +const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const cannedInputs = { + done: "What's done?", + left: "What's left?", +} as const; + +function currentOwnerId(req: Request): string | null { + return req.user?.id ? String(req.user.id) : null; +} + +function parseArtifacts(value: GoalRow['artifact_refs']): unknown[] { + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return []; + try { return JSON.parse(value) as unknown[]; } catch { return []; } +} + +async function serializeGoal(db: Knex, row: GoalRow) { + const latestHistory = await db('task_history') + .where({ task_id: row.current_task_id }) + .orderBy('timestamp', 'desc') + .first(); + const endMs = row.completed_at ? new Date(row.completed_at).getTime() : Date.now(); + const startMs = row.started_at ? new Date(row.started_at).getTime() : new Date(row.created_at).getTime(); + const currentPauseMs = row.desired_state === 'paused' && row.paused_at + ? Math.max(0, Date.now() - new Date(row.paused_at).getTime()) + : 0; + const pausedMs = Number(row.paused_ms || 0) + currentPauseMs; + const elapsedMs = Math.max(0, endMs - startMs); + return { + id: row.goal_id, + owner: row.owner_login, + repository: row.repository, + objective: row.objective, + baseBranch: row.base_branch, + branchName: row.branch_name, + worktreePath: row.worktree_path, + agent: { id: row.agent_id, alias: row.agent_alias, type: row.agent_type }, + requestedModel: row.requested_model, + effectiveModel: row.effective_model, + maxParallelTasks: row.max_parallel_tasks, + ultrafix: row.ultrafix == null ? null : Boolean(row.ultrafix), + desiredState: row.desired_state, + resultState: row.result_state, + taskId: row.current_task_id, + sessionId: row.session_id, + conversationId: row.conversation_id, + finalPr: row.final_pr_url ? { number: row.final_pr_number, url: row.final_pr_url } : null, + artifacts: parseArtifacts(row.artifact_refs), + taskState: latestHistory?.state ?? 'pending', + createdAt: row.created_at, + updatedAt: row.updated_at, + startedAt: row.started_at, + pausedAt: row.paused_at, + completedAt: row.completed_at, + elapsedMs, + pausedMs, + activeMs: Math.max(0, elapsedMs - pausedMs), + }; +} + +async function findOwnedGoal(db: Knex, req: Request, res: Response): Promise { + const ownerId = currentOwnerId(req); + if (!ownerId) { + res.status(401).json({ error: 'Authentication required' }); + return null; + } + const goalId = Array.isArray(req.params.goalId) ? req.params.goalId[0] : req.params.goalId; + const row = await db('goals').where({ goal_id: goalId, owner_id: ownerId }).first() as GoalRow | undefined; + if (!row) res.status(404).json({ error: 'Goal not found' }); + return row ?? null; +} + +function validateCreateBody(body: Record): string | null { + if (typeof body.repository !== 'string' || !repositoryPattern.test(body.repository)) return 'repository must be in owner/repo format'; + if (typeof body.objective !== 'string' || body.objective.trim().length < 1 || body.objective.length > 65_536) return 'objective is required'; + if (typeof body.agentId !== 'string' || !body.agentId) return 'agentId is required'; + if (typeof body.model !== 'string' || !body.model) return 'model is required'; + if (body.baseBranch != null && (typeof body.baseBranch !== 'string' || body.baseBranch.length > 255)) return 'baseBranch is invalid'; + if (body.maxParallelTasks != null && (!Number.isSafeInteger(body.maxParallelTasks) || Number(body.maxParallelTasks) < 1 || Number(body.maxParallelTasks) > 32)) return 'maxParallelTasks must be an integer from 1 to 32'; + if (body.ultrafix != null && typeof body.ultrafix !== 'boolean') return 'ultrafix must be a boolean'; + return null; +} + +export function createGoalRoutes(deps: GoalRoutesDeps) { + const getCapabilities = deps.getCapabilities ?? (async () => { + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + return registry.getGoalCapabilities(); + }); + const stop = deps.stopExecution ?? stopTaskExecution; + + /** Protect existing task/log surfaces when their authoritative task belongs to a goal. */ + const requireGoalTaskOwnership = async (req: Request, res: Response, next: () => void) => { + const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId; + const sessionId = Array.isArray(req.params.sessionId) ? req.params.sessionId[0] : req.params.sessionId; + const correlationId = Array.isArray(req.params.correlationId) ? req.params.correlationId[0] : req.params.correlationId; + let goal: Pick | undefined; + if (taskId?.startsWith('goal-')) { + goal = await deps.db('goals').select('goal_id', 'owner_id').where({ current_task_id: taskId }).first() as Pick | undefined; + } else if (sessionId) { + goal = await deps.db('goals') + .select('goals.goal_id', 'goals.owner_id') + .join('llm_executions', 'llm_executions.task_id', 'goals.current_task_id') + .where('llm_executions.session_id', sessionId) + .first() as Pick | undefined; + } else if (correlationId) { + goal = await deps.db('goals').select('goal_id', 'owner_id').where({ goal_id: correlationId }).first() as Pick | undefined; + } + if (goal && goal.owner_id !== currentOwnerId(req)) { + res.status(404).json({ error: 'Task not found' }); + return; + } + if (goal && !['GET', 'HEAD'].includes(req.method)) { + res.status(409).json({ error: 'Use the goal controls to mutate a native goal task' }); + return; + } + next(); + }; + + const capabilities = async (_req: Request, res: Response) => { + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + const detected = await getCapabilities(); + const agents = detected.map(capability => { + const agent = registry.getAgentById(capability.agentId); + return { + ...capability, + models: agent?.config.supportedModels ?? [], + defaultModel: agent?.config.defaultModel ?? null, + }; + }); + res.json({ agents }); + }; + + const list = async (req: Request, res: Response) => { + const ownerId = currentOwnerId(req); + if (!ownerId) return void res.status(401).json({ error: 'Authentication required' }); + const rows = await deps.db('goals').where({ owner_id: ownerId }).orderBy('updated_at', 'desc').limit(200); + res.json({ goals: await Promise.all(rows.map(row => serializeGoal(deps.db, row))) }); + }; + + const get = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (row) res.json({ goal: await serializeGoal(deps.db, row) }); + }; + + const create = async (req: Request, res: Response) => { + const body = (req.body ?? {}) as Record; + const validationError = validateCreateBody(body); + if (validationError) return void res.status(400).json({ error: validationError }); + const ownerId = currentOwnerId(req); + if (!ownerId) return void res.status(401).json({ error: 'Authentication required' }); + + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + const agent = registry.getAgentById(body.agentId as string) || registry.getAgentByAlias(body.agentId as string); + if (!agent) return void res.status(400).json({ error: 'Selected agent was not found' }); + if (!agent.config.supportedModels.includes(body.model as string) && agent.config.defaultModel !== body.model) { + return void res.status(400).json({ error: 'Selected model is not supported by this agent' }); + } + const capability = (await getCapabilities()).find(item => item.agentId === agent.config.id); + if (!capability?.goalCapable) return void res.status(409).json({ error: capability?.reason || 'Selected agent does not support native /goal' }); + + const [repoOwner, repoName] = (body.repository as string).split('/'); + try { + const octokit = await getAuthenticatedOctokit(); + await octokit.request('GET /repos/{owner}/{repo}', { owner: repoOwner, repo: repoName }); + } catch { + return void res.status(403).json({ error: 'Repository is not accessible to this ProPR installation' }); + } + + const goalId = randomUUID(); + const taskId = `goal-${goalId}`; + const now = new Date().toISOString(); + const row = { + goal_id: goalId, + owner_id: ownerId, + owner_login: req.user!.username, + repository: body.repository, + objective: body.objective as string, + base_branch: body.baseBranch || null, + agent_id: agent.config.id, + agent_alias: agent.config.alias, + agent_type: agent.config.type, + requested_model: body.model, + max_parallel_tasks: body.maxParallelTasks || null, + ultrafix: body.ultrafix == null ? null : body.ultrafix, + desired_state: 'running', + current_task_id: taskId, + run_generation: 0, + artifact_refs: JSON.stringify([]), + created_at: now, + updated_at: now, + }; + await deps.db('goals').insert(row); + const data: GoalJobData = { + goalId, taskId, repoOwner, repoName, generation: 0, + input: buildNativeGoalCommand(row.objective), + }; + try { + await deps.taskQueue.add('processGoal', data, { jobId: goalJobId(goalId, 0) }); + } catch (error) { + await deps.db('goals').where({ goal_id: goalId }).delete(); + throw error; + } + const inserted = await deps.db('goals').where({ goal_id: goalId }).first() as GoalRow; + res.status(201).json({ goal: await serializeGoal(deps.db, inserted) }); + }; + + const pause = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + if (row.desired_state === 'paused') return void res.json({ goal: await serializeGoal(deps.db, row) }); + if (!row.session_id) { + const activeJobs = await deps.taskQueue.getJobs(['active']); + if (activeJobs.some(job => (job.data as Partial)?.taskId === row.current_task_id)) { + return void res.status(409).json({ error: 'The provider session is still initializing; retry pause after its identity is persisted' }); + } + } + await deps.db('goals').where({ goal_id: row.goal_id, owner_id: row.owner_id }).update({ desired_state: 'paused', paused_at: deps.db.fn.now(), updated_at: deps.db.fn.now() }); + await stop(row.current_task_id, { + redisClient: deps.redisClient, + requestedBy: req.user!.username, + reason: 'Goal pause requested. Stop at the current provider boundary.', + cancellationReason: 'goal_paused', + markCancelled: async () => undefined, + }); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, updated!) }); + }; + + async function enqueueContinuation(row: GoalRow, input: string, res: Response, continuationKind: 'run' | 'input' = 'run'): Promise { + if (row.session_id && !row.worktree_path) return void res.status(409).json({ error: 'Goal session has no saved worktree' }); + if (!row.session_id && row.started_at) return void res.status(409).json({ error: 'Goal has not persisted a resumable provider session yet' }); + const activeJobs = await deps.taskQueue.getJobs(['active']); + if (activeJobs.some(job => (job.data as Partial)?.taskId === row.current_task_id)) { + return void res.status(409).json({ error: 'Goal is still reaching a safe provider boundary; retry shortly' }); + } + const generation = row.run_generation + 1; + const completedPauseMs = row.paused_at ? Math.max(0, Date.now() - new Date(row.paused_at).getTime()) : 0; + const changed = await deps.db('goals') + .where({ goal_id: row.goal_id, owner_id: row.owner_id, run_generation: row.run_generation }) + .update({ desired_state: 'running', paused_at: null, paused_ms: Number(row.paused_ms || 0) + completedPauseMs, run_generation: generation, updated_at: deps.db.fn.now() }); + if (changed !== 1) return void res.status(409).json({ error: 'Goal continuation was already queued' }); + await deps.redisClient.del(`worker:abort:${row.current_task_id}`); + const [repoOwner, repoName] = row.repository.split('/'); + try { + await deps.taskQueue.add('processGoal', { + goalId: row.goal_id, taskId: row.current_task_id, repoOwner, repoName, generation, input, continuationKind, + } satisfies GoalJobData, { jobId: goalJobId(row.goal_id, generation) }); + } catch (error) { + await deps.db('goals').where({ goal_id: row.goal_id, run_generation: generation }).update({ + desired_state: 'paused', paused_at: row.paused_at || deps.db.fn.now(), paused_ms: row.paused_ms || 0, + run_generation: row.run_generation, updated_at: deps.db.fn.now(), + }); + throw error; + } + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, updated!) }); + } + + const resume = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + if (row.desired_state !== 'paused') return void res.status(409).json({ error: 'Goal is not paused' }); + await enqueueContinuation(row, row.session_id ? GOAL_CONTINUE_INPUT : buildNativeGoalCommand(row.objective), res); + }; + + const cancel = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + if (row.result_state === 'cancelled') return void res.json({ goal: await serializeGoal(deps.db, row) }); + if (row.result_state) return void res.status(409).json({ error: 'Goal is already complete' }); + const finalPauseMs = row.paused_at ? Math.max(0, Date.now() - new Date(row.paused_at).getTime()) : 0; + await deps.db('goals').where({ goal_id: row.goal_id, owner_id: row.owner_id }).update({ + desired_state: 'cancelled', result_state: 'cancelled', paused_at: null, + paused_ms: Number(row.paused_ms || 0) + finalPauseMs, + completed_at: deps.db.fn.now(), updated_at: deps.db.fn.now(), + }); + await stop(row.current_task_id, { + redisClient: deps.redisClient, + requestedBy: req.user!.username, + reason: 'Goal cancelled by user.', + cancellationReason: 'goal_cancelled', + ensureCancelled: true, + }); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, updated!) }); + }; + + const requestModel = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + if (row.result_state) return void res.status(409).json({ error: 'Goal is terminal' }); + const model = req.body?.model; + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + const agent = registry.getAgentById(row.agent_id); + if (typeof model !== 'string' || !agent || (!agent.config.supportedModels.includes(model) && agent.config.defaultModel !== model)) return void res.status(400).json({ error: 'Unsupported model' }); + await deps.db('goals').where({ goal_id: row.goal_id, owner_id: row.owner_id }).update({ requested_model: model, updated_at: deps.db.fn.now() }); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, updated!) }); + }; + + const input = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + if (row.desired_state !== 'paused') return void res.status(409).json({ error: 'Pause the goal before sending a continuation' }); + const canned = req.body?.canned as keyof typeof cannedInputs | undefined; + const message = canned ? cannedInputs[canned] : req.body?.message; + if (typeof message !== 'string' || !message.trim() || message.length > 65_536) return void res.status(400).json({ error: 'A valid message or canned status request is required' }); + await enqueueContinuation(row, message.trim(), res, 'input'); + }; + + return { capabilities, list, get, create, pause, resume, cancel, requestModel, input, requireGoalTaskOwnership }; +} diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index 8c018e944..f8b534401 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -29,3 +29,4 @@ export { createUserRepoPreferencesRoutes } from './userRepoPreferencesRoutes.js' export { createAgentRuntimeRoutes } from './agentRuntimeRoutes.js'; export { createNotificationRoutes } from './notificationRoutes.js'; export { createAdminRoutes } from './adminRoutes.js'; +export { createGoalRoutes } from './goalRoutes.js'; diff --git a/packages/api/routes/llmLogsRoutes.ts b/packages/api/routes/llmLogsRoutes.ts index 13a1f0198..c1af3d017 100644 --- a/packages/api/routes/llmLogsRoutes.ts +++ b/packages/api/routes/llmLogsRoutes.ts @@ -72,6 +72,13 @@ function applyLlmLogFilters(query: T, filters: LlmL return query; } +function applyGoalOwnershipFilter(query: T, ownerId?: string): T { + return query.whereNotExists(function() { + this.select('*').from('goals').whereRaw('goals.current_task_id = llm_logs.task_id'); + if (ownerId) this.whereNot('goals.owner_id', ownerId); + }) as T; +} + interface UsageMetricRecordRow { id: number; llm_log_id: number; @@ -244,6 +251,7 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { /** Process-wide cache for work-ref column existence. Only caches `true` * so a process started before the migration will re-check until it lands. */ let hasWorkRefColumnsCache = false; + let hasGoalsTableCache = false; async function checkWorkRefColumns(): Promise { if (hasWorkRefColumnsCache) return true; @@ -256,6 +264,17 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { } } + async function checkGoalsTable(): Promise { + if (hasGoalsTableCache) return true; + try { + const result = await db.schema.hasTable('goals'); + if (result) hasGoalsTableCache = true; + return result; + } catch { + return false; + } + } + async function getLlmLogs(req: Request, res: Response): Promise { try { // Validate pagination parameters @@ -310,6 +329,7 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { // Check if work-reference columns exist (cached after first successful check) const hasWorkRefColumns = await checkWorkRefColumns(); + const hasGoalsTable = await checkGoalsTable(); // Build and execute queries const baseColumns = [ @@ -327,9 +347,11 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { ? [...baseColumns, ...workRefColumns] : baseColumns; - const baseQuery = db('llm_logs').select(...selectColumns); - - const countQuery = db('llm_logs').count('* as count'); + const ownerId = req.user?.id ? String(req.user.id) : undefined; + const rawBaseQuery = db('llm_logs').select(...selectColumns); + const rawCountQuery = db('llm_logs').count('* as count'); + const baseQuery = hasGoalsTable ? applyGoalOwnershipFilter(rawBaseQuery, ownerId) : rawBaseQuery; + const countQuery = hasGoalsTable ? applyGoalOwnershipFilter(rawCountQuery, ownerId) : rawCountQuery; // If work_type filter is requested but the column doesn't exist, return empty results if (!hasWorkRefColumns && workType) { diff --git a/packages/api/routes/taskHelpers.ts b/packages/api/routes/taskHelpers.ts index a986ea47c..98cd004f5 100644 --- a/packages/api/routes/taskHelpers.ts +++ b/packages/api/routes/taskHelpers.ts @@ -83,6 +83,7 @@ export async function getTasksFromDb( `; const baseQuery = db('tasks as t') + .whereNot('t.task_type', 'goal') .join(latestHistorySubquery, function() { this.on('t.task_id', '=', 'h.task_id').andOn('h.rn', '=', db!.raw('?', [1])); }) diff --git a/packages/api/server.ts b/packages/api/server.ts index 2c6651eea..7bc7fe6c0 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -31,6 +31,7 @@ import { createUserRepoPreferencesRoutes, createAgentRuntimeRoutes, createNotificationRoutes, createAdminRoutes, + createGoalRoutes, createInstanceCatalogRoutes, attachmentUpload } from './routes/index.js'; @@ -277,8 +278,13 @@ function setupRoutes(): void { const adminRoutes = createAdminRoutes(); const instanceCatalogRoutes = createInstanceCatalogRoutes(); const agentVersionRoutes = createAgentVersionRoutes(); + const goalRoutes = createGoalRoutes({ db, taskQueue, redisClient }); + + app.use(['/api/task/:taskId', '/api/task/:taskId/*path', '/api/tasks/:taskId', '/api/execution/:sessionId', '/api/execution/:sessionId/*path', '/api/llm-metrics/:correlationId'], goalRoutes.requireGoalTaskOwnership); const operationalRoutes: RouteEntry[] = [ + ['get', '/api/goals/capabilities', goalRoutes.capabilities], ['get', '/api/goals', goalRoutes.list], ['post', '/api/goals', goalRoutes.create], ['get', '/api/goals/:goalId', goalRoutes.get], + ['post', '/api/goals/:goalId/pause', goalRoutes.pause], ['post', '/api/goals/:goalId/resume', goalRoutes.resume], ['post', '/api/goals/:goalId/cancel', goalRoutes.cancel], ['patch', '/api/goals/:goalId/model', goalRoutes.requestModel], ['post', '/api/goals/:goalId/input', goalRoutes.input], ['get', '/api/status', statusRoutes.getStatus], ['get', '/api/tasks', taskRoutes.getTasks], ['get', '/api/tasks/revert-preview', taskRoutes.getRevertPreview], ['post', '/api/tasks/revert', taskRoutes.revertChanges], ['post', '/api/tasks/:taskId/followup', taskRoutes.postFollowup], ...createTaskDeleteRouteEntries({ taskRoutes }), ['get', '/api/task/:taskId/history', taskHistoryRoutes.getTaskHistory], ['get', '/api/task/:taskId/live-details', liveDetailsRoutes.getLiveDetails], ['get', '/api/task/:taskId/file-changes', fileChangesRoutes.getFileChanges], ['get', '/api/queue/stats', queueRoutes.getQueueStats], ['get', '/api/activity', queueRoutes.getActivity], ['get', '/api/metrics', queueRoutes.getMetrics], diff --git a/packages/api/services/socketSubscriptions.ts b/packages/api/services/socketSubscriptions.ts index 0afa5ab60..9723fabb2 100644 --- a/packages/api/services/socketSubscriptions.ts +++ b/packages/api/services/socketSubscriptions.ts @@ -132,11 +132,19 @@ export class SocketSubscriptionManager { if (socketSubscriptions.size === 0) this.pendingSubscriptions.delete(socket); } - async taskExists(taskId: string): Promise { + async taskExists(taskId: string, socket?: Socket): Promise { const queueDependencies = this.dependencies.getQueueDependencies(); if (!queueDependencies) return false; try { const stateKey = `${queueDependencies.workerStateOptions?.keyPrefix ?? 'worker:state:'}${taskId}`; + if (taskId.startsWith('goal-')) { + if (!socket) return false; + const goal = await queueDependencies.db('goals') + .select('owner_id') + .where({ current_task_id: taskId }) + .first() as { owner_id?: string } | undefined; + if (!goal || goal.owner_id !== this.getPrincipal(socket).user.id) return false; + } if (await queueDependencies.redisClient.get(stateKey)) return true; const task = await queueDependencies.db('tasks') .select('task_id') @@ -221,7 +229,7 @@ export class SocketSubscriptionManager { if (!await this.join(socket, { event: 'subscribe:task', room: taskRoom(taskId), - authorize: () => this.taskExists(taskId), + authorize: () => this.taskExists(taskId, socket), })) return; console.log(`[SocketService] Client ${socket.id} subscribed to task:${taskId}`); }); @@ -241,7 +249,7 @@ export class SocketSubscriptionManager { await this.join(socket, { event: 'subscribe:task:live', room: `task:live:${taskId}`, - authorize: () => this.taskExists(taskId), + authorize: () => this.taskExists(taskId, socket), onJoined: async () => { await this.dependencies.taskWatcherManager.startTaskWatcher(taskId); await this.dependencies.taskWatcherManager.sendTaskLiveUpdate(taskId, true); diff --git a/packages/api/services/taskWatcher.ts b/packages/api/services/taskWatcher.ts index 7841cc891..0aa981d05 100644 --- a/packages/api/services/taskWatcher.ts +++ b/packages/api/services/taskWatcher.ts @@ -88,7 +88,7 @@ export class TaskWatcherManager { } // Get agent config to find the correct log path - const agentConfig = await findAgentConfigForTask(taskId); + const agentConfig = await findAgentConfigForTask(taskId, this.deps?.db); const agentType = agentConfig?.type || 'claude'; const agentRoot = agentConfig ? resolveConfigPath(agentConfig.configPath) : path.join(os.homedir(), '.claude'); diff --git a/packages/api/services/taskWatcherLookup.ts b/packages/api/services/taskWatcherLookup.ts index 0954203f4..a9b0dcb1a 100644 --- a/packages/api/services/taskWatcherLookup.ts +++ b/packages/api/services/taskWatcherLookup.ts @@ -10,9 +10,27 @@ export interface TaskWatcherLookupDeps { /** * Find the agent config for a task based on agent alias in task ID */ -export async function findAgentConfigForTask(taskId: string): Promise { +export async function findAgentConfigForTask(taskId: string, database?: Knex): Promise { try { const agents = await loadAgents(); + + // Goal task IDs are deliberately stable and provider-agnostic so that a + // continuation can reuse the same task identity. Resolve their immutable + // provider selection from the goal envelope instead of guessing from the + // task ID. Output, todos and usage continue to come from the normal task + // watcher/parsers. + if (database && taskId.startsWith('goal-')) { + const goal = await database('goals') + .where({ current_task_id: taskId }) + .first('agent_id', 'agent_alias', 'agent_type'); + if (goal) { + const selected = agents.find(agent => agent.id === goal.agent_id) + ?? agents.find(agent => agent.alias === goal.agent_alias) + ?? agents.find(agent => agent.type === goal.agent_type); + if (selected) return selected; + } + } + for (const agent of agents) { if (taskId.includes(`-${agent.alias}-`)) { return agent; diff --git a/packages/api/test/goalRoutes.test.ts b/packages/api/test/goalRoutes.test.ts new file mode 100644 index 000000000..c15963a2d --- /dev/null +++ b/packages/api/test/goalRoutes.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { Request, Response } from 'express'; +import knex from 'knex'; +import { closeConnection } from '@propr/core'; +import { up as createGoals } from '../../core/src/db/migrations/20260902000000_create_goals.js'; +import { createGoalRoutes } from '../routes/goalRoutes.js'; + +function request(userId: string, params: Record = {}, body: unknown = {}): Request { + return { user: { id: userId, username: userId }, params, body } as unknown as Request; +} + +function response() { + const state: { status: number; body?: unknown } = { status: 200 }; + const res = { + status(code: number) { state.status = code; return this; }, + json(body: unknown) { state.body = body; return this; }, + } as unknown as Response; + return { res, state }; +} + +test('goal routes keep metadata owner-scoped and queue ordinary input on the same task/session', async () => { + const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const queued: Array<{ name: string; data: Record; options: { jobId: string } }> = []; + const stopped: string[] = []; + try { + await createGoals(database); + await database.schema.createTable('task_history', table => { + table.increments('id'); + table.string('task_id'); + table.string('state'); + table.timestamp('timestamp'); + }); + const common = { + owner_login: 'alice', repository: 'acme/repo', objective: 'Ship it', + agent_id: 'agent-1', agent_alias: 'codex', agent_type: 'codex', requested_model: 'gpt-5.6', + desired_state: 'paused', run_generation: 2, session_id: 'thread-1', + branch_name: 'goal/ship-it', worktree_path: '/worktrees/goal-1', + }; + await database('goals').insert([ + { ...common, goal_id: 'goal-1', owner_id: 'owner-1', current_task_id: 'goal-task-1' }, + { ...common, goal_id: 'goal-2', owner_id: 'owner-2', current_task_id: 'goal-task-2' }, + ]); + const routes = createGoalRoutes({ + db: database, + taskQueue: { + getJobs: async () => [], + add: async (name: string, data: Record, options: { jobId: string }) => { + queued.push({ name, data, options }); + }, + } as never, + redisClient: { del: async () => 1 } as never, + stopExecution: async taskId => { stopped.push(taskId); return {} as never; }, + getCapabilities: async () => [], + }); + + const listed = response(); + await routes.list(request('owner-1'), listed.res); + assert.equal(listed.state.status, 200); + assert.deepEqual((listed.state.body as { goals: Array<{ id: string }> }).goals.map(goal => goal.id), ['goal-1']); + + const hidden = response(); + await routes.get(request('owner-1', { goalId: 'goal-2' }), hidden.res); + assert.equal(hidden.state.status, 404); + + const taskHidden = response(); + let nextCalled = false; + await routes.requireGoalTaskOwnership( + request('owner-2', { taskId: 'goal-task-1' }), + taskHidden.res, + () => { nextCalled = true; }, + ); + assert.equal(taskHidden.state.status, 404); + assert.equal(nextCalled, false); + + const genericMutation = response(); + const mutationRequest = request('owner-1', { taskId: 'goal-task-1' }); + mutationRequest.method = 'POST'; + await routes.requireGoalTaskOwnership(mutationRequest, genericMutation.res, () => { nextCalled = true; }); + assert.equal(genericMutation.state.status, 409); + + const metricHidden = response(); + await routes.requireGoalTaskOwnership( + request('owner-2', { correlationId: 'goal-1' }), metricHidden.res, () => { nextCalled = true; }, + ); + assert.equal(metricHidden.state.status, 404); + + const continued = response(); + await routes.input(request('owner-1', { goalId: 'goal-1' }, { message: 'Focus on the API first.' }), continued.res); + assert.equal(continued.state.status, 200); + assert.equal(queued.length, 1); + assert.deepEqual(queued[0], { + name: 'processGoal', + data: { + goalId: 'goal-1', taskId: 'goal-task-1', repoOwner: 'acme', repoName: 'repo', + generation: 3, input: 'Focus on the API first.', continuationKind: 'input', + }, + options: { jobId: 'goal-goal-1-3' }, + }); + const updated = await database('goals').where({ goal_id: 'goal-1' }).first(); + assert.equal(updated.session_id, 'thread-1'); + assert.equal(updated.current_task_id, 'goal-task-1'); + assert.equal(updated.worktree_path, '/worktrees/goal-1'); + assert.equal(updated.desired_state, 'running'); + + const cancelled = response(); + await routes.cancel(request('owner-2', { goalId: 'goal-2' }), cancelled.res); + assert.equal(cancelled.state.status, 200); + assert.deepEqual(stopped, ['goal-task-2']); + assert.equal((await database('goals').where({ goal_id: 'goal-2' }).first()).result_state, 'cancelled'); + } finally { + await database.destroy(); + await closeConnection(); + } +}); diff --git a/packages/core/src/agents/AgentRegistry.ts b/packages/core/src/agents/AgentRegistry.ts index 04ec1fe72..ba32d58a7 100644 --- a/packages/core/src/agents/AgentRegistry.ts +++ b/packages/core/src/agents/AgentRegistry.ts @@ -16,6 +16,7 @@ import { AGENT_DEFAULT_VERSIONS } from './version/types.js'; import { DEFAULT_AGENT_DOCKER_IMAGES } from './constants.js'; import { loadAgentRuntimePackageState, resolveAgentRuntimeImage } from './runtime/agentRuntimePackages.js'; import { AGENT_DEFAULTS } from '../config/modelDefinitions.js'; +import { GOAL_CAPABILITY_COMMANDS, helpAdvertisesNativeGoal, type GoalCapability } from './goalCapabilities.js'; export interface AgentRegistryOperationalStatus { unifiedAgentImage: { @@ -46,6 +47,7 @@ export class AgentRegistry { private pendingBackgroundRefresh: Promise | null = null; private unavailableUnifiedAgentImage: { imageTag?: string; error: string; recordedAt: string } | null = null; private unifiedAgentImageRetryTimer: NodeJS.Timeout | null = null; + private goalCapabilityCache = new Map(); private constructor() { // Private constructor for singleton pattern @@ -85,6 +87,7 @@ export class AgentRegistry { // Clear existing maps this.agents.clear(); this.agentsByAlias.clear(); + this.goalCapabilityCache.clear(); if (configs.length === 0) { // Fallback: Create default Claude agent from ENV vars if no config exists @@ -223,6 +226,55 @@ export class AgentRegistry { return Array.from(this.agents.values()); } + /** + * Capability-probes the exact configured image instead of assuming a + * provider name implies support. Results are cached until registry refresh. + */ + async getGoalCapabilities(): Promise { + return Promise.all(this.getAllAgents().map(async agent => { + const cached = this.goalCapabilityCache.get(agent.config.id); + if (cached) return cached; + + const command = GOAL_CAPABILITY_COMMANDS[agent.config.type]; + let capability: GoalCapability; + if (!agent.goalCapable || !command) { + capability = { + agentId: agent.config.id, + agentAlias: agent.config.alias, + agentType: agent.config.type, + goalCapable: false, + reason: 'Provider does not implement native goal mode', + }; + } else { + try { + const result = await executeDockerCommand('docker', [ + 'run', '--rm', '--entrypoint', command, + agent.config.dockerImage, '--help', + ], { timeout: 30_000 }); + const help = `${result.stdout}\n${result.stderr}`; + const supported = result.exitCode === 0 && helpAdvertisesNativeGoal(help); + capability = { + agentId: agent.config.id, + agentAlias: agent.config.alias, + agentType: agent.config.type, + goalCapable: supported, + ...(!supported && { reason: 'Pinned CLI does not advertise native /goal support' }), + }; + } catch (error) { + capability = { + agentId: agent.config.id, + agentAlias: agent.config.alias, + agentType: agent.config.type, + goalCapable: false, + reason: `Capability probe failed: ${(error as Error).message}`, + }; + } + } + this.goalCapabilityCache.set(agent.config.id, capability); + return capability; + })); + } + /** * Gets all agent configurations (including disabled ones from config). */ diff --git a/packages/core/src/agents/goalCapabilities.ts b/packages/core/src/agents/goalCapabilities.ts new file mode 100644 index 000000000..fba1d6e44 --- /dev/null +++ b/packages/core/src/agents/goalCapabilities.ts @@ -0,0 +1,21 @@ +import type { AgentType } from './types.js'; + +export interface GoalCapability { + agentId: string; + agentAlias: string; + agentType: AgentType; + goalCapable: boolean; + reason?: string; +} + +/** Native goal mode is deliberately limited to providers with documented goal/session support. */ +export function helpAdvertisesNativeGoal(helpText: string): boolean { + return /(?:^|\s)\/goal(?:\s|$|[<[])/im.test(helpText) + || /native\s+goal(?:s|\s+mode)?/i.test(helpText); +} + +export const GOAL_CAPABILITY_COMMANDS: Partial> = { + claude: 'claude', + codex: 'codex', + antigravity: 'agy', +}; diff --git a/packages/core/src/agents/impl/AntigravityAgent.ts b/packages/core/src/agents/impl/AntigravityAgent.ts index 98c86aa7b..b5f6c52fb 100644 --- a/packages/core/src/agents/impl/AntigravityAgent.ts +++ b/packages/core/src/agents/impl/AntigravityAgent.ts @@ -86,6 +86,7 @@ function getAntigravityTranscriptRoot(): string { export class AntigravityAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = true; private readonly timeoutMs: number; constructor(config: AgentConfig) { @@ -123,7 +124,7 @@ export class AntigravityAgent implements Agent { } async executeTask(options: AgentTaskOptions): Promise { - const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, environment, taskId, prNumber } = options; + const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, environment, taskId, prNumber, executionMode = 'task', resumeSessionId, resumeConversationId } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; const transcriptPath = this.createTransientTranscriptPath(taskId); @@ -134,10 +135,10 @@ export class AntigravityAgent implements Agent { }, isRetry ? 'Starting Antigravity agent execution (RETRY)...' : 'Starting Antigravity agent execution...'); try { - const prompt = this.buildPromptWithRetryContext(customPrompt, isRetry, retryReason); + const prompt = executionMode === 'goal' ? customPrompt : this.buildPromptWithRetryContext(customPrompt, isRetry, retryReason); await setWorktreeOwnership(worktreePath, issueRef.number); const worktreeGitContent = verifyWorktreeStructure(worktreePath, issueRef.number); - const dockerArgs = this.buildDockerArgs({ worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, environment, taskId, transcriptPath }); + const dockerArgs = this.buildDockerArgs({ worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, environment, taskId, transcriptPath, executionMode, resumeConversationId: resumeConversationId || resumeSessionId }); const { result, usageMetrics } = await executeWithUsageTracking( this.getRuntimeName(), @@ -433,13 +434,13 @@ export class AntigravityAgent implements Agent { return ['set -e', `exec ${this.getCliCommand()} ${safetyArgs} "$@"`].join('\n'); } - private buildDockerArgs(params: { worktreePath: string; githubToken: string; modelName?: string; issueNumber: number; environment?: Record; taskId?: string; executionType?: string; transcriptPath?: string; readOnlyWorkspace?: boolean; repositoryInspection?: boolean }): string[] { - const { worktreePath, githubToken, modelName, issueNumber, environment, taskId, executionType, transcriptPath, readOnlyWorkspace = false, repositoryInspection = false } = params; + private buildDockerArgs(params: { worktreePath: string; githubToken: string; modelName?: string; issueNumber: number; environment?: Record; taskId?: string; executionType?: string; transcriptPath?: string; readOnlyWorkspace?: boolean; repositoryInspection?: boolean; executionMode?: 'task' | 'goal'; resumeConversationId?: string }): string[] { + const { worktreePath, githubToken, modelName, issueNumber, environment, taskId, executionType, transcriptPath, readOnlyWorkspace = false, repositoryInspection = false, executionMode = 'task', resumeConversationId } = params; assertRepositoryInspectionMode(repositoryInspection, readOnlyWorkspace); const configPath = this.getHostConfigPath(); const envVars = buildAgentEnvironmentArgs(repositoryInspection, this.config.envVars, environment); const shortTaskId = createContainerExecutionId(taskId); - const taskType = executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); + const taskType = executionMode === 'goal' ? 'goal' : executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); const runtimeName = this.getRuntimeName(); const containerName = this.buildContainerName(this.config.alias || runtimeName, taskType, shortTaskId, modelName); const dockerArgs: string[] = [ @@ -450,7 +451,8 @@ export class AntigravityAgent implements Agent { ...(repositoryInspection ? [] : ['-e', `GH_TOKEN=${githubToken}`, '-e', `GITHUB_TOKEN=${githubToken}`]), '-e', 'ANTIGRAVITY_CLI=1', '-e', 'ANTIGRAVITY_CLI_TRUST_WORKSPACE=true', ...(readOnlyWorkspace ? ['-e', 'PROPR_REPO_SETUP=0'] : []), - '-e', 'PROPR_EPHEMERAL_STATE=1', '-e', `PROPR_ANTIGRAVITY_SOURCE_CONFIG=${this.getContainerConfigPath()}`, + ...(executionMode === 'task' ? ['-e', 'PROPR_EPHEMERAL_STATE=1'] : []), + '-e', `PROPR_ANTIGRAVITY_SOURCE_CONFIG=${this.getContainerConfigPath()}`, ...(repositoryInspection ? [ '-e', 'PROPR_REPOSITORY_INSPECTION=1', '-e', `PROPR_REPOSITORY_SCOUT_ANTIGRAVITY_MCP_CONFIG=${buildAntigravityRepositoryScoutMcpConfig()}`, @@ -471,6 +473,7 @@ export class AntigravityAgent implements Agent { dockerArgs.push('--model', cleanModelName); logger.info({ issueNumber, requestedModel: cleanModelName, originalModel: modelName, agentAlias: this.config.alias }, 'Model specified for Antigravity agent'); } else { logger.debug({ issueNumber, agentAlias: this.config.alias }, 'No model specified, Antigravity agent will use default'); } + if (executionMode === 'goal' && resumeConversationId) dockerArgs.push('--conversation', resumeConversationId); logger.info({ issueNumber, agentAlias: this.config.alias }, 'Docker args built for Antigravity agent'); return wrapDockerRunArgsWithRepoSetup(dockerArgs, this.config.dockerImage, runtimeName); } diff --git a/packages/core/src/agents/impl/ClaudeAgent.ts b/packages/core/src/agents/impl/ClaudeAgent.ts index 5013516a7..f986e8aa0 100644 --- a/packages/core/src/agents/impl/ClaudeAgent.ts +++ b/packages/core/src/agents/impl/ClaudeAgent.ts @@ -78,6 +78,7 @@ export function resolveAnalysisOutcome(claudeOutput: ClaudeOutput, stderr: strin export class ClaudeAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = true; private readonly maxTurns: number; private readonly timeoutMs: number; @@ -92,7 +93,8 @@ export class ClaudeAgent implements Agent { const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, - onSessionId, onContainerId, githubToken, tools, environment, taskId, prNumber, reasoningLevel + onSessionId, onContainerId, githubToken, tools, environment, taskId, prNumber, reasoningLevel, + executionMode = 'task', resumeSessionId } = options; const startTime = Date.now(); @@ -107,7 +109,7 @@ export class ClaudeAgent implements Agent { }, isRetry ? 'Starting Claude agent execution (RETRY)...' : 'Starting Claude agent execution...'); try { - const prompt = buildClaudePrompt({ + const prompt = executionMode === 'goal' ? customPrompt : buildClaudePrompt({ customPrompt, issueRef, branchName, modelName: effectiveModel, issueDetails, isRetry, retryReason }); @@ -118,7 +120,7 @@ export class ClaudeAgent implements Agent { const dockerArgs = buildDockerArgs(this.config, this.maxTurns, { worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, systemPrompt, tools, environment, taskId, - reasoningLevel: effectiveReasoningLevel + reasoningLevel: effectiveReasoningLevel, executionMode, resumeSessionId }); const { result, usageMetrics } = await executeWithUsageTracking( diff --git a/packages/core/src/agents/impl/CodexAgent.ts b/packages/core/src/agents/impl/CodexAgent.ts index ad8dcbc76..517cce42e 100644 --- a/packages/core/src/agents/impl/CodexAgent.ts +++ b/packages/core/src/agents/impl/CodexAgent.ts @@ -30,6 +30,7 @@ type CodexUsageMetrics = Awaited>['u export class CodexAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = true; private readonly maxTurns: number; private readonly timeoutMs: number; @@ -42,7 +43,8 @@ export class CodexAgent implements Agent { async executeTask(options: AgentTaskOptions): Promise { const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, - onSessionId, onContainerId, githubToken, environment, taskId, prNumber, reasoningLevel } = options; + onSessionId, onContainerId, githubToken, environment, taskId, prNumber, reasoningLevel, + executionMode = 'task', resumeSessionId } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; @@ -53,7 +55,7 @@ export class CodexAgent implements Agent { }, isRetry ? 'Starting Codex agent execution (RETRY)...' : 'Starting Codex agent execution...'); try { - const prompt = buildCodexPrompt({ + const prompt = executionMode === 'goal' ? customPrompt : buildCodexPrompt({ customPrompt, issueRef, branchName, modelName: effectiveModel, issueDetails, isRetry, retryReason, systemPrompt }); @@ -63,7 +65,7 @@ export class CodexAgent implements Agent { const dockerArgs = this.buildDockerArgs({ worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, environment, taskId, - reasoningLevel: effectiveReasoningLevel + reasoningLevel: effectiveReasoningLevel, executionMode, resumeSessionId }); const { result, usageMetrics } = await executeWithUsageTracking( diff --git a/packages/core/src/agents/impl/OpenCodeAgent.ts b/packages/core/src/agents/impl/OpenCodeAgent.ts index 766a298c1..4c674b89e 100644 --- a/packages/core/src/agents/impl/OpenCodeAgent.ts +++ b/packages/core/src/agents/impl/OpenCodeAgent.ts @@ -43,6 +43,7 @@ function buildFailedExecutionResult(error: Error & { stderr?: string }, executio export class OpenCodeAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = false; private readonly timeoutMs: number; constructor(config: AgentConfig) { diff --git a/packages/core/src/agents/impl/VibeAgent.ts b/packages/core/src/agents/impl/VibeAgent.ts index 9723b517f..fa09d46de 100644 --- a/packages/core/src/agents/impl/VibeAgent.ts +++ b/packages/core/src/agents/impl/VibeAgent.ts @@ -48,6 +48,7 @@ interface VibeDockerArgsParams { export class VibeAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = false; private readonly maxTurns: number; private readonly timeoutMs: number; diff --git a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts index cb3c24c6a..801b79252 100644 --- a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts +++ b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts @@ -45,12 +45,15 @@ export interface CodexDockerArgsParams { reasoningLevel?: CodexRuntimeReasoningLevel | ''; readOnlyWorkspace?: boolean; repositoryInspection?: boolean; + executionMode?: 'task' | 'goal'; + resumeSessionId?: string; } export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArgsParams): string[] { const { worktreePath, githubToken, modelName, issueNumber, jsonOutput = true, environment, taskId, executionType, reasoningLevel, readOnlyWorkspace = false, repositoryInspection = false, + executionMode = 'task', resumeSessionId, } = params; if (repositoryInspection && !readOnlyWorkspace) { throw new Error('Repository inspection requires a read-only workspace'); @@ -60,7 +63,7 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg const configPath = resolveConfigPath(config.configPath); const envVars = buildEnvironmentVariableArgs([config.envVars, environment], repositoryInspection); const shortTaskId = createContainerExecutionId(taskId); - const taskType = executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); + const taskType = executionMode === 'goal' ? 'goal' : executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); const containerName = `${config.alias || 'codex'}-${taskType}-${shortTaskId}`; const workspaceTarget = repositoryInspection ? REPOSITORY_SCOUT_CONTAINER_ROOT : '/home/node/workspace'; const dockerArgs: string[] = [ @@ -80,14 +83,23 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg ...envVars, '-w', '/home/node/workspace', dockerImage, - 'codex', 'exec', '--ephemeral', + 'codex', 'exec', + ...(executionMode === 'task' ? ['--ephemeral'] : []), + ...(executionMode === 'goal' && resumeSessionId ? ['resume'] : []), ...(jsonOutput ? ['--json'] : []), ...(repositoryInspection ? buildCodexRepositoryScoutArgs() - : ['--dangerously-bypass-approvals-and-sandbox', '--config', 'features.multi_agent=false']), + : [ + '--dangerously-bypass-approvals-and-sandbox', + // Normal ProPR tasks retain their one-shot single-agent + // contract. Native goals leave decomposition and subagents to + // Codex itself. + ...(executionMode === 'task' ? ['--config', 'features.multi_agent=false'] : []), + ]), ...(reasoningLevel ? ['--config', `model_reasoning_effort="${reasoningLevel}"`] : []), '--skip-git-repo-check', '--cd', '/home/node/workspace', + ...(executionMode === 'goal' && resumeSessionId ? [resumeSessionId] : []), '-' ]; diff --git a/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts b/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts index 2c0792005..d3fedc182 100644 --- a/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts +++ b/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts @@ -71,6 +71,9 @@ export interface DockerArgsParams { readOnlyWorkspace?: boolean; /** Expose only the root-confined repository scout MCP tools. */ repositoryInspection?: boolean; + /** Preserve provider state and use native session resume semantics. */ + executionMode?: 'task' | 'goal'; + resumeSessionId?: string; } function repositoryInspectionArgs(enabled: boolean): string[] { @@ -115,10 +118,13 @@ function buildBaseDockerArgs(options: { inspectionArgs: string[]; reasoningLevel?: ClaudeRuntimeReasoningLevel | ''; readOnlyWorkspace: boolean; + executionMode: 'task' | 'goal'; + resumeSessionId?: string; }): string[] { const { config, maxTurns, worktreePath, workspaceMountTarget, configPath, containerName, githubToken, envVars, claudeJsonMount, inspectionArgs, reasoningLevel, readOnlyWorkspace, + executionMode, resumeSessionId, } = options; return [ 'run', '--rm', '-i', @@ -138,8 +144,9 @@ function buildBaseDockerArgs(options: { '-w', '/home/node/workspace', config.dockerImage, 'claude', '-p', '-', - '--no-session-persistence', - '--max-turns', maxTurns.toString(), + ...(executionMode === 'task' ? ['--no-session-persistence'] : []), + ...(executionMode === 'goal' && resumeSessionId ? ['--resume', resumeSessionId] : []), + ...(executionMode === 'task' ? ['--max-turns', maxTurns.toString()] : []), '--output-format', 'stream-json', '--verbose', ...inspectionArgs, @@ -170,6 +177,7 @@ export function buildDockerArgs( const { worktreePath, githubToken, modelName, issueNumber, systemPrompt, tools, environment, taskId, executionType, reasoningLevel, readOnlyWorkspace = false, repositoryInspection = false, + executionMode = 'task', resumeSessionId, } = params; const configPath = resolveConfigPath(config.configPath); if (repositoryInspection && !readOnlyWorkspace) { @@ -188,13 +196,15 @@ export function buildDockerArgs( worktreePath, workspaceMountTarget, configPath, - containerName: buildClaudeContainerName(config, issueNumber, taskId, executionType), + containerName: buildClaudeContainerName(config, issueNumber, taskId, executionMode === 'goal' ? 'goal' : executionType), githubToken, envVars, claudeJsonMount: optionalClaudeJsonMount(), inspectionArgs, reasoningLevel, readOnlyWorkspace, + executionMode, + resumeSessionId, }); // Add model parameter if specified @@ -202,7 +212,8 @@ export function buildDockerArgs( // Strip agent prefix if present (e.g., "claude:claude-opus-4-6" -> "claude-opus-4-6") const cleanModelName = modelName.includes(':') ? modelName.split(':').pop()! : modelName; const maxTurnsIndex = dockerArgs.indexOf('--max-turns'); - dockerArgs.splice(maxTurnsIndex, 0, '--model', cleanModelName); + const modelIndex = maxTurnsIndex >= 0 ? maxTurnsIndex : dockerArgs.indexOf('--output-format'); + dockerArgs.splice(modelIndex, 0, '--model', cleanModelName); logger.info({ issueNumber, requestedModel: cleanModelName, diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 5579bc5e7..4c9d55266 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -45,6 +45,17 @@ export interface AgentTaskOptions { issueDetails?: IssueDetails; prompt: string; + /** + * Selects the provider's native long-running goal path. Goal input is + * delivered verbatim and provider session persistence is retained. + * Omitted for the existing one-shot task behavior. + */ + executionMode?: 'task' | 'goal'; + /** Exact provider session identity to resume in goal mode. */ + resumeSessionId?: string; + /** Provider conversation identity when it differs from the session ID. */ + resumeConversationId?: string; + // Execution overrides model?: string; systemPrompt?: string; @@ -188,6 +199,9 @@ export type AgentTerminationReason = 'timeout' | 'max_turns'; export interface Agent { readonly config: AgentConfig; + /** Whether this provider implementation has a native goal execution path. */ + readonly goalCapable: boolean; + /** * Executes a complex task modifying files in the worktree. * Typically runs inside a Docker container. diff --git a/packages/core/src/claude/docker/dockerExecutor.ts b/packages/core/src/claude/docker/dockerExecutor.ts index 41698d18a..9f26ccfeb 100644 --- a/packages/core/src/claude/docker/dockerExecutor.ts +++ b/packages/core/src/claude/docker/dockerExecutor.ts @@ -56,7 +56,7 @@ export interface DockerCommandOptions { signal?: AbortSignal; } -interface JsonLineMessage { type?: string; message?: { id?: string; model?: string; }; session_id?: string; conversation_id?: string; } +interface JsonLineMessage { type?: string; event?: string; message?: { id?: string; model?: string; }; session_id?: string; conversation_id?: string; thread_id?: string; init?: { conversation_id?: string }; } // ANSI escape code regex for stripping terminal formatting (constructed dynamically to avoid control char lint errors) const ANSI_REGEX = new RegExp('[' + String.fromCharCode(0x1b) + String.fromCharCode(0x9b) + '][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]', 'g'); @@ -171,7 +171,7 @@ export function executeDockerCommand(command: string, args: string[], options: D const namedContainer = command === 'docker' ? getDockerRunContainerName(executionArgs) : null; const child = spawnCommandProcess(executablePath, executionArgs, cwd, stdinData); - let stdout = '', stderr = ''; + let stdout = '', stderr = '', sessionLineBuffer = ''; const state = createDockerExecutionState(); let ownershipFailure: unknown; let hasOwnershipFailure = false; @@ -215,6 +215,28 @@ export function executeDockerCommand(command: string, args: string[], options: D pendingCallbacks.add(callbackPromise); void callbackPromise.finally(() => pendingCallbacks.delete(callbackPromise)); }; + const inspectSessionLines = (chunk: string, timestamp: string, flush = false): void => { + sessionLineBuffer += chunk; + const lines = sessionLineBuffer.split('\n'); + const remainder = lines.pop() ?? ''; + sessionLineBuffer = flush ? '' : remainder; + if (flush && remainder) lines.push(remainder); + for (const line of lines) { + if (!line.trim()) continue; + try { + const message: JsonLineMessage = JSON.parse(line); + if (message.type === 'assistant' || message.type === 'user') { + messageTimestamps.set(message.message?.id || `${message.type}-${JSON.stringify(message).substring(0, 100)}`, timestamp); + } + const detectedSessionId = message.session_id || message.thread_id + || (message.event === 'init' ? message.conversation_id || message.init?.conversation_id : undefined); + if (!state.sessionIdDetected && onSessionId && detectedSessionId) { + state.sessionIdDetected = true; + invokeExecutionCallback(() => onSessionId(detectedSessionId, message.conversation_id || message.init?.conversation_id)); + } + } catch { /* non-JSON provider output */ } + } + }; executionSignal?.addEventListener('abort', abortForExecutionSignal, { once: true }); const timeoutHandle = setTimeout(() => { state.timedOut = true; @@ -256,22 +278,13 @@ export function executeDockerCommand(command: string, args: string[], options: D child.stdout?.on('data', (data: Buffer) => { const chunk = data.toString(), ts = new Date().toISOString(); stdout += chunk; - for (const line of chunk.split('\n')) { - if (!line.trim()) continue; - try { - const j: JsonLineMessage = JSON.parse(line); - if (j.type === 'assistant' || j.type === 'user') messageTimestamps.set(j.message?.id || `${j.type}-${JSON.stringify(j).substring(0, 100)}`, ts); - if (!state.sessionIdDetected && onSessionId && j.session_id) { - state.sessionIdDetected = true; - invokeExecutionCallback(() => onSessionId(j.session_id!, j.conversation_id)); - } - } catch { /* skip */ } - } + inspectSessionLines(chunk, ts); }); child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); child.on('close', async (exitCode: number | null) => { clearTimeout(timeoutHandle); + inspectSessionLines('', new Date().toISOString(), true); if (containerDetectionTimer) clearTimeout(containerDetectionTimer); if (abortChecker) await abortChecker.close(); await Promise.allSettled([...pendingCallbacks]); @@ -302,6 +315,7 @@ export function executeDockerCommand(command: string, args: string[], options: D }); child.on('error', async (error: Error) => { clearTimeout(timeoutHandle); + inspectSessionLines('', new Date().toISOString(), true); if (containerDetectionTimer) clearTimeout(containerDetectionTimer); executionSignal?.removeEventListener('abort', abortForExecutionSignal); if (abortChecker) await abortChecker.close(); diff --git a/packages/core/src/db/migrations/20260902000000_create_goals.js b/packages/core/src/db/migrations/20260902000000_create_goals.js new file mode 100644 index 000000000..445039f52 --- /dev/null +++ b/packages/core/src/db/migrations/20260902000000_create_goals.js @@ -0,0 +1,47 @@ +/** + * Minimal durable envelope for native coding-agent goals. Task history, output, + * todos, token usage, containers and execution logs remain in their existing + * stores and are referenced through current_task_id. + */ +export async function up(knex) { + await knex.schema.createTable('goals', table => { + table.uuid('goal_id').primary(); + table.string('owner_id', 255).notNullable(); + table.string('owner_login', 255).notNullable(); + table.string('repository', 255).notNullable(); + table.text('objective').notNullable(); + table.string('base_branch', 255); + table.string('branch_name', 255); + table.text('worktree_path'); + table.string('agent_id', 255).notNullable(); + table.string('agent_alias', 255).notNullable(); + table.string('agent_type', 50).notNullable(); + table.string('requested_model', 255).notNullable(); + table.string('effective_model', 255); + table.integer('max_parallel_tasks'); + table.boolean('ultrafix'); + table.string('desired_state', 20).notNullable().defaultTo('running'); + table.string('result_state', 20); + table.string('current_task_id', 255).notNullable().unique(); + table.string('session_id', 255); + table.string('conversation_id', 255); + table.integer('run_generation').notNullable().defaultTo(0); + table.integer('final_pr_number'); + table.text('final_pr_url'); + table.json('artifact_refs').defaultTo('[]'); + table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('started_at'); + table.timestamp('paused_at'); + table.bigInteger('paused_ms').notNullable().defaultTo(0); + table.timestamp('completed_at'); + + table.index(['owner_id', 'updated_at']); + table.index(['desired_state', 'result_state']); + table.index('repository'); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('goals'); +} diff --git a/packages/core/src/goals.ts b/packages/core/src/goals.ts new file mode 100644 index 000000000..fd763d6ab --- /dev/null +++ b/packages/core/src/goals.ts @@ -0,0 +1,27 @@ +export type GoalDesiredState = 'running' | 'paused' | 'cancelled'; +export type GoalResultState = 'completed' | 'failed' | 'cancelled'; + +export const GOAL_CONTINUE_INPUT = 'Continue working toward the goal.'; + +/** The first provider input is intentionally not decorated with ProPR prompts. */ +export function buildNativeGoalCommand(objective: string): string { + return `/goal ${objective}`; +} + +export function goalJobId(goalId: string, generation: number): string { + return `goal-${goalId}-${generation}`; +} + +export function buildGoalPolicyEnvironment(options: { + maxParallelTasks?: number | null; + ultrafix?: boolean | null; +}): Record { + const environment: Record = { PROPR_EXECUTION_MODE: 'goal' }; + if (options.maxParallelTasks != null) { + environment.PROPR_GOAL_MAX_PARALLEL_TASKS = String(options.maxParallelTasks); + } + if (options.ultrafix != null) { + environment.PROPR_GOAL_ULTRAFIX = options.ultrafix ? 'enabled' : 'disabled'; + } + return environment; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9efd78e4b..7036123b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export type { IssueJobData, CommentJobData, TaskImportJobData, + GoalJobData, AnalysisJobData, SystemTaskJobData, IndexingJobData, @@ -314,6 +315,10 @@ export { processDetectedIssue, fetchIssuesForRepo } from './daemon/issueDetectio // Agent abstraction exports export { AgentRegistry, getAgentRegistry } from './agents/AgentRegistry.js'; export type { AgentRegistryOperationalStatus } from './agents/AgentRegistry.js'; +export { helpAdvertisesNativeGoal, GOAL_CAPABILITY_COMMANDS } from './agents/goalCapabilities.js'; +export type { GoalCapability } from './agents/goalCapabilities.js'; +export { buildNativeGoalCommand, buildGoalPolicyEnvironment, goalJobId, GOAL_CONTINUE_INPUT } from './goals.js'; +export type { GoalDesiredState, GoalResultState } from './goals.js'; export { describeAgentTermination, isIncompleteAgentExecution, resolveAgentTerminationReason } from './agents/termination.js'; export { ClaudeAgent } from './agents/impl/ClaudeAgent.js'; export { CodexAgent } from './agents/impl/CodexAgent.js'; diff --git a/packages/core/src/queue/taskQueue.ts b/packages/core/src/queue/taskQueue.ts index 0019a8146..4f4bf8d08 100644 --- a/packages/core/src/queue/taskQueue.ts +++ b/packages/core/src/queue/taskQueue.ts @@ -10,6 +10,7 @@ export type { CommentJobData, UnprocessedComment, TaskImportJobData, + GoalJobData, AnalysisJobData, SystemTaskJobData, IndexingJobData, @@ -30,6 +31,7 @@ export type { import type { IssueJobData, CommentJobData, + GoalJobData, AnalysisJobData, IndexingJobData, JobData, @@ -59,7 +61,7 @@ const connectionOptions: RedisOptions = { // Lazy-initialized Redis connection and queues let redisConnection: Redis | null = null; -let _issueQueue: Queue | null = null; +let _issueQueue: Queue | null = null; let _analysisQueue: Queue | null = null; let _indexingQueue: Queue | null = null; let isInitialized = false; @@ -106,7 +108,7 @@ async function ensureInitialized(): Promise { }, }; - _issueQueue = new Queue(GITHUB_ISSUE_QUEUE_NAME, issueQueueOptions); + _issueQueue = new Queue(GITHUB_ISSUE_QUEUE_NAME, issueQueueOptions); _issueQueue.on('error', (err: Error) => { logger.error({ queue: GITHUB_ISSUE_QUEUE_NAME, err }, 'Queue error'); }); @@ -162,7 +164,7 @@ async function ensureInitialized(): Promise { /** * Get the issue queue, initializing if needed. */ -export async function getIssueQueue(): Promise> { +export async function getIssueQueue(): Promise> { await ensureInitialized(); return _issueQueue!; } @@ -186,7 +188,7 @@ export async function getIndexingQueue(): Promise> { // Legacy synchronous exports for backward compatibility // These will throw if accessed before initialization // Use getIssueQueue(), getAnalysisQueue(), getIndexingQueue() for safe access -export const issueQueue = new Proxy({} as Queue, { +export const issueQueue = new Proxy({} as Queue, { get(_target, prop) { if (!_issueQueue) { throw new Error('issueQueue accessed before initialization. Use getIssueQueue() instead or call ensureInitialized() first.'); diff --git a/packages/core/src/queue/taskQueue.types.ts b/packages/core/src/queue/taskQueue.types.ts index 09058f621..01675f092 100644 --- a/packages/core/src/queue/taskQueue.types.ts +++ b/packages/core/src/queue/taskQueue.types.ts @@ -101,6 +101,20 @@ export interface TaskImportJobData { user?: string; } +/** One continuation of the same native provider goal task/session. */ +export interface GoalJobData { + goalId: string; + taskId: string; + repoOwner: string; + repoName: string; + generation: number; + /** Exact initial native command or an ordinary same-session continuation. */ + input?: string; + recovery?: boolean; + /** Ordinary replies do not by themselves declare the provider-owned goal complete. */ + continuationKind?: 'run' | 'input'; +} + export interface AnalysisJobData { taskId: string; executionId: string; @@ -151,7 +165,7 @@ export interface MergeConflictJobData { systemGenerated: true; // Distinguishes from user-authored follow-up comments } -export type JobData = IssueJobData | CommentJobData | TaskImportJobData | AnalysisJobData | SystemTaskJobData | IndexingJobData | MergeConflictJobData; +export type JobData = IssueJobData | CommentJobData | TaskImportJobData | GoalJobData | AnalysisJobData | SystemTaskJobData | IndexingJobData | MergeConflictJobData; export interface ClaudeOutputResult { type?: string; diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx index 76cebf0f3..33dedac22 100644 --- a/propr-ui/src/App.tsx +++ b/propr-ui/src/App.tsx @@ -35,6 +35,7 @@ const RevertPage = lazy(() => import('./pages/RevertPage')) const SettingsPage = lazy(() => import('./pages/SettingsPage')) const SummaryBrowserPage = lazy(() => import('./pages/SummaryBrowserPage')) const TasksPage = lazy(() => import('./pages/TasksPage')) +const GoalsPage = lazy(() => import('./pages/GoalsPage')) type CompatibilityState = | { status: 'checking' } @@ -269,6 +270,8 @@ const AppContent: React.FC = () => { } /> + } /> + } /> (path: string, init?: RequestInit): Promise { + const response = await apiFetch(`${API_BASE_URL}${path}`, { + credentials: 'include', + ...init, + headers: init?.body ? { 'Content-Type': 'application/json', ...init.headers } : init?.headers, + }); + await handleApiResponse(response); + return response.json(); +} + +export const getGoalCapabilities = async () => + request<{ agents: GoalCapability[] }>('/api/goals/capabilities'); +export const listGoals = async () => request<{ goals: Goal[] }>('/api/goals'); +export const getGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}`); +export const createGoal = async (body: { repository: string; objective: string; agentId: string; model: string; baseBranch?: string; maxParallelTasks?: number; ultrafix?: boolean }) => + request<{ goal: Goal }>('/api/goals', { method: 'POST', body: JSON.stringify(body) }); +export const pauseGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/pause`, { method: 'POST' }); +export const resumeGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/resume`, { method: 'POST' }); +export const cancelGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); +export const requestGoalModel = async (id: string, model: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/model`, { method: 'PATCH', body: JSON.stringify({ model }) }); +export const sendGoalInput = async (id: string, body: { message?: string; canned?: 'done' | 'left' }) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/input`, { method: 'POST', body: JSON.stringify(body) }); diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx index be7d98f19..5dbae3c14 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { ScrollText, ListTodo, BookMarked, Bot, Cpu, ShieldCheck, Inbox } from 'lucide-react'; +import { ScrollText, ListTodo, BookMarked, Bot, Cpu, ShieldCheck, Inbox, Target } from 'lucide-react'; import { logout } from '../api/proprApi'; import { useDynamicFavicon } from '../hooks/useDynamicFavicon'; import { useSystemReadiness } from '../hooks/useSystemReadiness'; @@ -53,6 +53,7 @@ const Layout: React.FC = ({ children }) => { { name: 'Dashboard', href: '/', icon: HomeIcon }, { name: 'Inbox', href: '/inbox', icon: Inbox }, { name: 'Plans', href: '/plans', icon: ScrollText }, + { name: 'Goals', href: '/goals', icon: Target }, { name: 'Tasks', href: '/tasks', icon: ListTodo }, { name: 'Repositories', href: '/repositories', icon: BookMarked }, ...(userHasPermission(user, 'instance.manage_agents') diff --git a/propr-ui/src/components/MobileBottomNavigation.tsx b/propr-ui/src/components/MobileBottomNavigation.tsx index bcaf241de..35e64caf6 100644 --- a/propr-ui/src/components/MobileBottomNavigation.tsx +++ b/propr-ui/src/components/MobileBottomNavigation.tsx @@ -15,6 +15,7 @@ import { MoreHorizontal, ScrollText, Settings, + Target, ShieldCheck, X, } from 'lucide-react'; @@ -50,13 +51,14 @@ const getNavigationState = (pathname: string) => { more: pathname === '/' || pathMatches(pathname, '/plans') || (pathMatches(pathname, '/studio') && !newPlan) || pathMatches(pathname, '/ai-agents') || pathMatches(pathname, '/llm-logs') || - pathMatches(pathname, '/settings') || pathMatches(pathname, '/admin/members'), + pathMatches(pathname, '/settings') || pathMatches(pathname, '/admin/members') || pathMatches(pathname, '/goals'), }; }; const getMoreItems = (user: CurrentUser | null) => [ { label: 'Dashboard', to: '/', icon: Home }, { label: 'Plans', to: '/plans', icon: ScrollText }, + { label: 'Goals', to: '/goals', icon: Target }, ...(userHasPermission(user, 'instance.manage_agents') ? [{ label: 'Coding Agents', to: '/ai-agents', icon: Bot }] : []), diff --git a/propr-ui/src/pages/GoalsPage.test.tsx b/propr-ui/src/pages/GoalsPage.test.tsx new file mode 100644 index 000000000..e6d228495 --- /dev/null +++ b/propr-ui/src/pages/GoalsPage.test.tsx @@ -0,0 +1,109 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import GoalsPage from './GoalsPage'; +import * as goalsApi from '../api/goals'; +import { getInstanceCatalog, getTaskLiveDetails } from '../api/proprApi'; + +vi.mock('../api/goals', () => ({ + getGoalCapabilities: vi.fn(), listGoals: vi.fn(), getGoal: vi.fn(), createGoal: vi.fn(), + pauseGoal: vi.fn(), resumeGoal: vi.fn(), cancelGoal: vi.fn(), requestGoalModel: vi.fn(), sendGoalInput: vi.fn(), +})); +vi.mock('../api/proprApi', () => ({ getInstanceCatalog: vi.fn(), getTaskLiveDetails: vi.fn() })); +const socket = vi.hoisted(() => ({ + isConnected: false as boolean, subscribeToTask: vi.fn(), unsubscribeFromTask: vi.fn(), + subscribeToTaskLive: vi.fn(), unsubscribeFromTaskLive: vi.fn(), + onTaskUpdate: vi.fn((handler?: (payload: never) => void) => { void handler; return vi.fn(); }), + onTaskLiveUpdate: vi.fn((handler?: (payload: never) => void) => { void handler; return vi.fn(); }), +})); +vi.mock('../contexts/useSocket', () => ({ useSocket: () => socket })); + +const capability = { + agentId: 'agent-1', agentAlias: 'codex', agentType: 'codex', goalCapable: true, + models: ['gpt-5.6', 'gpt-5.6-fast'], defaultModel: 'gpt-5.6', +}; +const goal: goalsApi.Goal = { + id: 'goal-1', owner: 'owner', repository: 'acme/web', objective: 'Ship the dashboard', + baseBranch: null, branchName: 'goal/dashboard', worktreePath: '/tmp/worktree', + agent: { id: 'agent-1', alias: 'codex', type: 'codex' }, requestedModel: 'gpt-5.6', effectiveModel: 'gpt-5.6', + maxParallelTasks: 3, ultrafix: true, desiredState: 'running', resultState: null, + taskId: 'goal-task-1', sessionId: 'thread-1', conversationId: null, finalPr: null, artifacts: [], + taskState: 'claude_execution', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + startedAt: new Date().toISOString(), pausedAt: null, completedAt: null, elapsedMs: 1000, activeMs: 1000, pausedMs: 0, +}; + +describe('GoalsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + socket.isConnected = false; + socket.onTaskUpdate.mockImplementation(() => vi.fn()); + socket.onTaskLiveUpdate.mockImplementation(() => vi.fn()); + vi.mocked(goalsApi.getGoalCapabilities).mockResolvedValue({ agents: [capability] }); + vi.mocked(getInstanceCatalog).mockResolvedValue({ agents: [], repositories: [{ name: 'acme/web', enabled: true }] }); + vi.mocked(goalsApi.listGoals).mockResolvedValue({ goals: [] }); + vi.mocked(goalsApi.getGoal).mockResolvedValue({ goal }); + vi.mocked(getTaskLiveDetails).mockResolvedValue({ events: [], todos: [{ id: 'todo-1', content: 'Implement API', status: 'in_progress' }], currentTask: 'Implement API', tokenUsage: { input_tokens: 10, output_tokens: 5 } }); + vi.mocked(goalsApi.pauseGoal).mockResolvedValue({ goal: { ...goal, desiredState: 'paused', pausedAt: new Date().toISOString() } }); + vi.mocked(goalsApi.sendGoalInput).mockResolvedValue({ goal }); + vi.mocked(goalsApi.cancelGoal).mockResolvedValue({ goal: { ...goal, desiredState: 'cancelled', resultState: 'cancelled' } }); + vi.mocked(goalsApi.requestGoalModel).mockResolvedValue({ goal: { ...goal, requestedModel: 'gpt-5.6-fast' } }); + }); + + it('creates exactly one native goal from repository, agent, model and objective', async () => { + vi.mocked(goalsApi.createGoal).mockResolvedValue({ goal }); + render(} />Goal detail} />); + await screen.findByRole('option', { name: 'codex' }); + fireEvent.change(screen.getByLabelText('Objective'), { target: { value: 'Ship the dashboard' } }); + fireEvent.click(screen.getByRole('button', { name: 'Start native goal' })); + await waitFor(() => expect(goalsApi.createGoal).toHaveBeenCalledWith(expect.objectContaining({ repository: 'acme/web', agentId: 'agent-1', model: 'gpt-5.6', objective: 'Ship the dashboard' }))); + expect(await screen.findByText('Goal detail')).toBeInTheDocument(); + }); + + it('gates creation when the pinned provider lacks native goal capability', async () => { + vi.mocked(goalsApi.getGoalCapabilities).mockResolvedValue({ agents: [{ ...capability, goalCapable: false, reason: 'No /goal' }] }); + render(} />); + expect(await screen.findByText(/No pinned coding-agent CLI/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start native goal' })).toBeDisabled(); + }); + + it('renders existing task live details and sends canned status input through the same session', async () => { + render(} />); + expect((await screen.findAllByText('Implement API')).length).toBeGreaterThan(0); + expect(screen.getByText('15')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: "What's done?" })); + await waitFor(() => expect(goalsApi.pauseGoal).toHaveBeenCalledWith('goal-1')); + await waitFor(() => expect(goalsApi.sendGoalInput).toHaveBeenCalledWith('goal-1', { canned: 'done' })); + }); + + it('requests the next model separately from the effective model and exposes cancellation', async () => { + render(} />); + await screen.findByRole('button', { name: 'Cancel' }); + fireEvent.change(screen.getByLabelText('Model for next continuation'), { target: { value: 'gpt-5.6-fast' } }); + await waitFor(() => expect(goalsApi.requestGoalModel).toHaveBeenCalledWith('goal-1', 'gpt-5.6-fast')); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(goalsApi.cancelGoal).toHaveBeenCalledWith('goal-1')); + }); + + it('re-subscribes after reconnect and merges incremental native output', async () => { + socket.isConnected = true; + let liveHandler: ((payload: never) => void) | undefined; + socket.onTaskLiveUpdate.mockImplementation(handler => { + liveHandler = handler; + return vi.fn(); + }); + const page = () => } />; + const view = render(page()); + await waitFor(() => expect(socket.subscribeToTaskLive).toHaveBeenCalledWith('goal-task-1')); + act(() => liveHandler?.({ + taskId: 'goal-task-1', events: [{ id: 'next', type: 'assistant', content: 'Incremental update' }], + todos: [], currentTask: 'Testing', tokenUsage: null, + } as never)); + expect(await screen.findByText('Incremental update')).toBeInTheDocument(); + + socket.isConnected = false; + view.rerender(page()); + socket.isConnected = true; + view.rerender(page()); + await waitFor(() => expect(socket.subscribeToTaskLive).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/propr-ui/src/pages/GoalsPage.tsx b/propr-ui/src/pages/GoalsPage.tsx new file mode 100644 index 000000000..80c3ba079 --- /dev/null +++ b/propr-ui/src/pages/GoalsPage.tsx @@ -0,0 +1,192 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { CirclePause, CirclePlay, CircleStop, ExternalLink, Plus, Send } from 'lucide-react'; +import { getInstanceCatalog, getTaskLiveDetails } from '../api/proprApi'; +import type { InstanceCatalogRepository } from '../api/proprTypes'; +import { + cancelGoal, createGoal, getGoal, getGoalCapabilities, listGoals, pauseGoal, + requestGoalModel, resumeGoal, sendGoalInput, + type Goal, type GoalCapability, +} from '../api/goals'; +import type { LiveDetails } from '../components/TaskDetails/types'; +import { mergeIncrementalLiveDetails } from '../components/TaskDetails/useTaskData'; +import { useDocumentTitle } from '../hooks/useDocumentTitle'; +import { useSocket } from '../contexts/useSocket'; + +const buttonClass = 'inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50'; +const duration = (milliseconds: number) => { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds}s` : `${seconds}s`; +}; + +function GoalState({ goal }: { goal: Goal }) { + const state = goal.resultState || goal.desiredState; + const color = state === 'completed' ? 'bg-green-100 text-green-800' : state === 'failed' || state === 'cancelled' ? 'bg-red-100 text-red-800' : state === 'paused' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800'; + return {state}; +} + +function CreateGoalForm({ onCreated }: { onCreated: (goal: Goal) => void }) { + const [repositories, setRepositories] = useState([]); + const [agents, setAgents] = useState([]); + const [repository, setRepository] = useState(''); + const [agentId, setAgentId] = useState(''); + const [model, setModel] = useState(''); + const [objective, setObjective] = useState(''); + const [parallelism, setParallelism] = useState(''); + const [ultrafix, setUltrafix] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const selectedAgent = agents.find(agent => agent.agentId === agentId); + + useEffect(() => { + Promise.all([getInstanceCatalog(), getGoalCapabilities()]).then(([catalog, capabilityData]) => { + const capable = capabilityData.agents.filter(agent => agent.goalCapable); + setRepositories(catalog.repositories); + setAgents(capabilityData.agents); + setRepository(catalog.repositories[0]?.name || ''); + const initial = capable[0]; + if (initial) { + setAgentId(initial.agentId); + setModel(initial.defaultModel || initial.models[0] || ''); + } + }).catch(err => setError((err as Error).message)); + }, []); + + useEffect(() => { + if (selectedAgent && !selectedAgent.models.includes(model)) setModel(selectedAgent.defaultModel || selectedAgent.models[0] || ''); + }, [model, selectedAgent]); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const result = await createGoal({ + repository, agentId, model, objective, + ...(parallelism ? { maxParallelTasks: Number(parallelism) } : {}), + ultrafix, + }); + onCreated(result.goal); + } catch (err) { setError((err as Error).message); } + finally { setSubmitting(false); } + }; + + return ( +
+

Start a goal

+ {error &&

{error}

} + {agents.length > 0 && !agents.some(agent => agent.goalCapable) &&

No pinned coding-agent CLI currently advertises native /goal support.

} +
+ + + + +
+