devloop.js 5.52 KB
/**
 * DevLoop · REST Routes (P1)
 *
 *   POST /api/devloop/projects
 *     body: { company_id, name?, requirement, mode?, target_path?, mock? }
 *     → 创建项目 + 跑 PM / Architect 两个 Agent (mock 模式产出 PRD/SDD)
 *     → 返 { pid, root_path, pm: {...}, architect: {...} }
 *
 *   GET /api/devloop/projects/:pid/state
 *     → 返 { pid, project, units, prd_rev, sdd_rev, prd_site, sdd_site, timeline_tail }
 *
 *   GET /api/devloop/projects   (额外:列项目)
 *     → 返 { projects: [...] }
 *
 * 规格: docs/prd/newfunction/_spec/plan-migration.md · P1 验收
 */

const express = require('express');
const router = express.Router();

const loopController = require('../src/core/devloop/loop-controller');
const pathResolver = require('../services/devloop/path-resolver');
const sseBroker = require('../services/devloop/sse-broker');
const bugTracker = require('../services/devloop/bug-tracker');

const h = (fn) => async (req, res) => {
  try { await fn(req, res); }
  catch (e) {
    console.error(`[DevLoop] ${req.method} ${req.path}:`, e.message);
    res.status(e.statusCode || 500).json({ error: e.message });
  }
};

// POST /api/devloop/projects
router.post('/projects', h(async (req, res) => {
  const { company_id, requirement, mode, target_path, mock, name } = req.body || {};
  if (!company_id) {
    return res.status(400).json({ error: 'missing field: company_id' });
  }
  if (!requirement || typeof requirement !== 'string') {
    return res.status(400).json({ error: 'missing field: requirement' });
  }

  // 1. 创建项目 (sandbox 默认)
  const proj = await loopController.createProject({
    company_id,
    mode: mode || 'sandbox',
    target_path,
    idea: name ? `${name}: ${requirement}` : requirement,
  });

  // 2. PM → Architect (mock 模式)
  const phase = await loopController.runDocsPhase(proj.pid, requirement, {
    mock: mock !== false,
  });

  res.json({
    ok: true,
    pid: proj.pid,
    root_path: proj.root_path,
    mode: proj.mode,
    pm: {
      files_written: phase.pm.files_written.length,
      commit: phase.pm.commit?.sha || null,
      mock: phase.pm.mock,
    },
    architect: {
      files_written: phase.architect.files_written.length,
      commit: phase.architect.commit?.sha || null,
      mock: phase.architect.mock,
    },
  });
}));

// GET /api/devloop/projects
router.get('/projects', h(async (_req, res) => {
  const projects = pathResolver.listProjects();
  res.json({ projects });
}));

// POST /api/devloop/projects/:pid/run[?only=<unitId>] [body: { mockFixtureDir? }]
//   - 不带 only: 跑完整 runProject (P3)
//   - only=<unitId>: 单 unit drill-down (P2 兼容)
router.post('/projects/:pid/run', h(async (req, res) => {
  const { pid } = req.params;
  const { only } = req.query;
  const { mockFixtureDir } = req.body || {};
  try {
    if (only) {
      const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir });
      return res.json({
        ok: result.ok, pid, unit_id: only,
        attempts: result.attempts, final_state: result.final_state,
        history: result.history.map(h => ({
          agent: h.agent, attempt: h.attempt,
          ok: h.ok, files: h.files?.length || 0,
          commit: h.commit || null,
        })),
      });
    }
    // 完整 runProject
    const result = await loopController.runProject(pid, { mock: true, mockFixtureDir });
    return res.json({
      ok: result.ok, pid,
      final_state: result.final_state,
      l2_rounds: result.l2_rounds,
      run_id: result.run_id || null,
      reason: result.reason || null,
      history: result.history,
    });
  } catch (e) {
    if (/Unknown project/.test(e.message)) {
      return res.status(404).json({ error: 'unknown project: ' + pid });
    }
    if (/not found in project/.test(e.message)) {
      return res.status(404).json({ error: 'unknown unit: ' + (only || '?') });
    }
    throw e;
  }
}));

// GET /api/devloop/projects/:pid/stream — SSE
//   client may send Last-Event-ID header → broker replays from there
router.get('/projects/:pid/stream', (req, res) => {
  const { pid } = req.params;
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders?.();

  const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null;
  sseBroker.subscribe(pid, res, {
    replayFromOffset: lastEventId != null ? lastEventId : undefined,
  });
});

// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be
router.get('/projects/:pid/bugs', h(async (req, res) => {
  const { pid } = req.params;
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  const filter = {};
  if (req.query.status) filter.status = String(req.query.status).split(',');
  if (req.query.unit_id) filter.unit_id = req.query.unit_id;
  if (req.query.severity) filter.severity = String(req.query.severity).split(',');
  const bugs = bugTracker.list(pid, filter);
  res.json({ bugs, total: bugs.length });
}));

// GET /api/devloop/projects/:pid/state
router.get('/projects/:pid/state', h(async (req, res) => {
  try {
    const state = loopController.getProjectState(req.params.pid);
    res.json(state);
  } catch (e) {
    if (/Unknown project/.test(e.message)) {
      return res.status(404).json({ error: 'unknown project: ' + req.params.pid });
    }
    throw e;
  }
}));

module.exports = router;