devloop.js 10.8 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, mockFixtureDir } = 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 模式) — mockFixtureDir 可选,P3/P4 scenario 测试用
  const phase = await loopController.runDocsPhase(proj.pid, requirement, {
    mock: mock !== false,
    mockFixtureDir,
  });

  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,
  });
});

// P4: POST /api/devloop/projects/:pid/sign-off
//   body: { by?, note? }
//   400 if status !== 'shipped'
router.post('/projects/:pid/sign-off', h(async (req, res) => {
  const { pid } = req.params;
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  try {
    const meta = await loopController.signOff(pid, req.body || {});
    res.json({ ok: true, pid, status: meta.status });
  } catch (e) {
    if (e.name === 'InvalidProjectStatusTransition') {
      return res.status(400).json({ error: e.message, from: e.from, to: e.to });
    }
    throw e;
  }
}));

// P4: POST /api/devloop/projects/:pid/abandon
//   body: { by?, reason?, note? }
router.post('/projects/:pid/abandon', h(async (req, res) => {
  const { pid } = req.params;
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  try {
    const meta = await loopController.abandon(pid, req.body || {});
    res.json({ ok: true, pid, status: meta.status });
  } catch (e) {
    if (e.name === 'InvalidProjectStatusTransition') {
      return res.status(400).json({ error: e.message, from: e.from, to: e.to });
    }
    throw e;
  }
}));

// P4: POST /api/devloop/projects/:pid/feedback
//   body: { text (required), by?, mockFixtureDir? }
//   400 if status ∈ {shipped-final, abandoned}
router.post('/projects/:pid/feedback', h(async (req, res) => {
  const { pid } = req.params;
  const { text, by, mockFixtureDir } = req.body || {};
  if (!text) return res.status(400).json({ error: 'missing field: text' });
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  try {
    const r = await loopController.submitFeedback(pid, text, { by, mockFixtureDir });
    res.json(r);
  } catch (e) {
    if (e.name === 'InvalidProjectStatusTransition') {
      return res.status(400).json({ error: e.message, from: e.from, to: e.to });
    }
    throw e;
  }
}));

// 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 });
}));

// P5: GET /api/devloop/projects/:pid/timeline?limit=200
//   读 timeline.ndjson 最近 N 条 (按时间倒序),给 LoopLogView 用
router.get('/projects/:pid/timeline', h(async (req, res) => {
  const fs = require('fs');
  const { pid } = req.params;
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  const limit = Math.max(1, Math.min(2000, parseInt(req.query.limit, 10) || 200));
  const p = pathResolver.resolveTimelineLog(pid);
  if (!fs.existsSync(p)) return res.json({ events: [], total: 0 });
  const lines = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean);
  const events = lines.slice(-limit).reverse().map(l => {
    try { return JSON.parse(l); } catch { return null; }
  }).filter(Boolean);
  res.json({ events, total: events.length });
}));

// P5: GET /api/devloop/projects/:pid/changelog
//   读 changelog.ndjson 全部 (按时间倒序)
router.get('/projects/:pid/changelog', h(async (req, res) => {
  const fs = require('fs');
  const { pid } = req.params;
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  const p = pathResolver.resolveChangelog(pid);
  if (!fs.existsSync(p)) return res.json({ entries: [], total: 0 });
  const entries = fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean)
    .map(l => { try { return JSON.parse(l); } catch { return null; }}).filter(Boolean)
    .reverse();
  res.json({ entries, total: entries.length });
}));

// P5: GET /api/devloop/projects/:pid/units/:unitId
//   返回 unit 元数据 + 最近 N 次 runs (Tester result.json)
router.get('/projects/:pid/units/:unitId', h(async (req, res) => {
  const fs = require('fs'); const path = require('path');
  const { pid, unitId } = req.params;
  try { pathResolver.resolveProjectRoot(pid); }
  catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
  const unitDir = pathResolver.resolveUnitDir(pid, unitId);
  if (!fs.existsSync(unitDir)) return res.status(404).json({ error: 'unknown unit: ' + unitId });
  // 从 units.json 找 unit 元数据
  let unitMeta = null;
  try {
    const uj = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
    unitMeta = (uj.units || []).find(u => u.id === unitId) || null;
  } catch { /* ignore */ }
  // runs
  const runsDir = path.join(unitDir, 'runs');
  let runs = [];
  if (fs.existsSync(runsDir)) {
    const ids = fs.readdirSync(runsDir).filter(n => {
      try { return fs.statSync(path.join(runsDir, n)).isDirectory(); } catch { return false; }
    }).sort().reverse().slice(0, 3);
    for (const id of ids) {
      const rp = path.join(runsDir, id, 'result.json');
      let r = null;
      if (fs.existsSync(rp)) {
        try { r = JSON.parse(fs.readFileSync(rp, 'utf8')); } catch {}
      }
      runs.push({ run_id: id, result: r });
    }
  }
  // coder.log 最后 1KB 节选
  let coderLog = null;
  const clp = path.join(unitDir, 'coder.log');
  if (fs.existsSync(clp)) {
    const raw = fs.readFileSync(clp, 'utf8');
    coderLog = raw.length > 1024 ? raw.slice(-1024) : raw;
  }
  res.json({ unit: unitMeta, runs, coder_log_tail: coderLog });
}));

// 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;