| |
| |
| |
| |
| |
| |
| |
| |
| import { Hono } from 'hono'; |
| import { z } from 'zod'; |
| import type { Env } from '../types/env'; |
| import type { WorkflowJob, DeploymentResult } from '../types/workflow'; |
| import { createAuditEvent } from '../utils/audit'; |
|
|
| export const deployWorkflowRoute = new Hono<{ Bindings: Env }>(); |
|
|
| const DeployRequestSchema = z.object({ |
| jobId: z.string().min(1), |
| }); |
|
|
| deployWorkflowRoute.post('/deploy', async (c) => { |
| const body = await c.req.json().catch(() => null); |
| if (!body) return c.json({ success: false, error: 'Invalid JSON body' }, 400); |
|
|
| const parsed = DeployRequestSchema.safeParse(body); |
| if (!parsed.success) { |
| return c.json({ success: false, error: 'Validation failed', details: parsed.error.flatten() }, 400); |
| } |
|
|
| const { jobId } = parsed.data; |
| const raw = await c.env.WFO_CACHE.get(`job:${jobId}`, 'text'); |
| if (!raw) return c.json({ success: false, error: `Job ${jobId} not found` }, 404); |
|
|
| const job = JSON.parse(raw) as WorkflowJob; |
|
|
| |
| if (!job.compiledWorkflow) { |
| return c.json({ success: false, error: 'Workflow not compiled. Run /generate first.' }, 409); |
| } |
| if (!job.validationReport?.valid) { |
| return c.json({ |
| success: false, |
| error: 'Workflow failed validation. Fix issues before deploying.', |
| validationReport: job.validationReport, |
| }, 409); |
| } |
| if (!job.simulationReport?.passed) { |
| return c.json({ |
| success: false, |
| error: 'Workflow failed dry-run simulation. Fix issues before deploying.', |
| simulationReport: job.simulationReport, |
| }, 409); |
| } |
|
|
| |
| const credResp = await fetch(`${c.env.N8N_BASE_URL}/api/v1/credentials`, { |
| headers: { 'X-N8N-API-KEY': c.env.N8N_API_KEY }, |
| signal: AbortSignal.timeout(8000), |
| }).catch(() => null); |
|
|
| let availableCredentials: Array<{ id: string; name: string; type: string }> = []; |
| if (credResp?.ok) { |
| const credData = await credResp.json() as { data: typeof availableCredentials }; |
| availableCredentials = credData.data ?? []; |
| } |
|
|
| |
| const workflowPayload = { |
| ...job.compiledWorkflow, |
| active: false, |
| }; |
|
|
| let n8nResponse: Response; |
| let n8nWorkflowId = job.deploymentResult?.n8nWorkflowId; |
|
|
| try { |
| if (n8nWorkflowId) { |
| |
| n8nResponse = await fetch(`${c.env.N8N_BASE_URL}/api/v1/workflows/${n8nWorkflowId}`, { |
| method: 'PUT', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'X-N8N-API-KEY': c.env.N8N_API_KEY, |
| }, |
| body: JSON.stringify(workflowPayload), |
| signal: AbortSignal.timeout(10000), |
| }); |
| } else { |
| |
| n8nResponse = await fetch(`${c.env.N8N_BASE_URL}/api/v1/workflows`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'X-N8N-API-KEY': c.env.N8N_API_KEY, |
| }, |
| body: JSON.stringify(workflowPayload), |
| signal: AbortSignal.timeout(10000), |
| }); |
| } |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : 'n8n API unreachable'; |
| return c.json({ success: false, error: `Deployment failed: ${msg}`, jobId }, 502); |
| } |
|
|
| if (!n8nResponse.ok) { |
| const errText = await n8nResponse.text(); |
| const deploymentResult: DeploymentResult = { |
| jobId, |
| status: 'failed', |
| deployedAt: new Date().toISOString(), |
| errorMessage: `n8n API ${n8nResponse.status}: ${errText}`, |
| }; |
|
|
| await updateJobInKV(c.env.WFO_CACHE, jobId, job, { |
| state: 'simulated', |
| deploymentResult, |
| event: 'deployment.failed', |
| }); |
|
|
| return c.json({ success: false, error: deploymentResult.errorMessage, deploymentResult, jobId }, 500); |
| } |
|
|
| const n8nData = await n8nResponse.json() as { id: string }; |
| n8nWorkflowId = n8nData.id; |
|
|
| const deploymentResult: DeploymentResult = { |
| jobId, |
| n8nWorkflowId, |
| status: 'success', |
| deployedAt: new Date().toISOString(), |
| }; |
|
|
| await updateJobInKV(c.env.WFO_CACHE, jobId, job, { |
| state: 'deployed', |
| deploymentResult, |
| event: 'deployment.success', |
| credentialAnalysis: { |
| jobId, |
| allCredentialsPresent: availableCredentials.length > 0, |
| required: [], |
| available: availableCredentials, |
| missing: [], |
| analysedAt: new Date().toISOString(), |
| }, |
| }); |
|
|
| return c.json({ |
| success: true, |
| jobId, |
| n8nWorkflowId, |
| deploymentResult, |
| message: 'Workflow deployed successfully. It is NOT yet activated. Submit /approve to request activation.', |
| }); |
| }); |
|
|
| async function updateJobInKV( |
| kv: KVNamespace, |
| jobId: string, |
| job: WorkflowJob, |
| updates: Partial<WorkflowJob> & { event: string }, |
| ) { |
| const { event, ...rest } = updates; |
| const updated: WorkflowJob = { |
| ...job, |
| ...rest, |
| updatedAt: new Date().toISOString(), |
| auditLog: [ |
| ...job.auditLog, |
| createAuditEvent(jobId, event, 'system', rest as Record<string, unknown>), |
| ], |
| }; |
| await kv.put(`job:${jobId}`, JSON.stringify(updated), { expirationTtl: 86400 * 7 }); |
| } |
|
|