unit-registry.js 8.71 KB
/**
 * DevLoop · unitRegistry
 *
 * Unit 状态机 (M-2 + P-1) 的唯一真源。所有 markCoding/markTesting/pass/fail/
 * block/reset 都走 FSM 校验,非法转换抛 InvalidTransitionError。
 *
 * 持久化:通过 docStore 读写 units.json(不直接 fs IO)。
 * 事件:每次合法状态转换发 SSE `unit.state`(若 sseBroker 注入)。
 *
 * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-10
 *      docs/prd/newfunction/_spec/sdd-process.md · P-1
 *      docs/prd/newfunction/_spec/sdd-model.md · M-2
 */

const docStore = require('./doc-store');
const pathResolver = require('./path-resolver');

class InvalidTransitionError extends Error {
  constructor(from, to, unitId) {
    super(`Invalid state transition for ${unitId}: ${from} → ${to}`);
    this.name = 'InvalidTransitionError';
    this.from = from;
    this.to = to;
    this.unitId = unitId;
  }
}

// FSM 合法转换表(来自 sdd-process P-1)
// from → allowed [to] 集合
const TRANSITIONS = {
  pending: ['coding'],
  coding:  ['testing', 'failed'],
  testing: ['passed', 'failed'],
  failed:  ['coding', 'blocked'],
  passed:  ['pending'],          // L2 reset
  blocked: ['pending'],          // 人类干预后
};

function canTransition(from, to) {
  if (!TRANSITIONS[from]) return false;
  return TRANSITIONS[from].includes(to);
}

// sseBroker 是可选依赖。LoopController 启动时 attach 进来。
let sseBroker = null;
function attachSseBroker(broker) {
  sseBroker = broker;
}

// ────────────────────────────────────────────────────────────────
// 读
// ────────────────────────────────────────────────────────────────

function _loadUnits(pid) {
  const u = docStore.readUnits(pid);
  if (!u) throw new Error(`units.json not found for ${pid}`);
  return u;
}

function list(pid) {
  return _loadUnits(pid).units;
}

function get(pid, unitId) {
  const u = list(pid).find(x => x.id === unitId);
  if (!u) throw new Error(`unit ${unitId} not found in project ${pid}`);
  return u;
}

function pending(pid)  { return list(pid).filter(u => u.status === 'pending'); }
function passed(pid)   { return list(pid).filter(u => u.status === 'passed');  }
function blocked(pid)  { return list(pid).filter(u => u.status === 'blocked'); }
function failed(pid)   { return list(pid).filter(u => u.status === 'failed');  }
function anyBlocked(pid) { return blocked(pid).length > 0; }

/**
 * L2 闸门:全部 unit status=passed 且 contract_hash 与当前 SDD 一致。
 * 这里只检查 status;contract_hash 一致性由 LoopController 调用前先 verifyContractHash
 * (这是更深的检查,需要读每个 unit 的 contract.json,太重不放这里)。
 */
function l2GateOpen(pid) {
  const units = list(pid);
  if (units.length === 0) return false;
  return units.every(u => u.status === 'passed');
}

// ────────────────────────────────────────────────────────────────
// 写(FSM 转换)
// ────────────────────────────────────────────────────────────────

function _writeBack(pid, unitsJson) {
  docStore.writeUnits(pid, unitsJson, { skipValidation: false });
}

function _transition(pid, unitId, to, extra = {}) {
  const unitsJson = _loadUnits(pid);
  const unit = unitsJson.units.find(u => u.id === unitId);
  if (!unit) throw new Error(`unit ${unitId} not found in project ${pid}`);

  const from = unit.status;
  if (!canTransition(from, to)) {
    throw new InvalidTransitionError(from, to, unitId);
  }

  unit.status = to;
  Object.assign(unit, extra);
  _writeBack(pid, unitsJson);

  if (sseBroker) {
    sseBroker.emit(pid, 'unit.state', {
      unit_id: unitId,
      from, to,
      retries: unit.retries,
      reason: extra.reason || null,
    });
  }

  return unit;
}

function markCoding(pid, unitId) {
  return _transition(pid, unitId, 'coding');
}

function markTesting(pid, unitId) {
  return _transition(pid, unitId, 'testing');
}

function pass(pid, unitId, contractHash) {
  return _transition(pid, unitId, 'passed', { contract_hash: contractHash });
}

function fail(pid, unitId, reason) {
  // failed 时累加 retries
  const unitsJson = _loadUnits(pid);
  const unit = unitsJson.units.find(u => u.id === unitId);
  if (!unit) throw new Error(`unit ${unitId} not found`);

  if (!canTransition(unit.status, 'failed')) {
    throw new InvalidTransitionError(unit.status, 'failed', unitId);
  }

  const from = unit.status;
  unit.status = 'failed';
  unit.retries = (unit.retries || 0) + 1;
  unit.last_failure_reason = reason;
  _writeBack(pid, unitsJson);

  if (sseBroker) {
    sseBroker.emit(pid, 'unit.state', {
      unit_id: unitId, from, to: 'failed',
      retries: unit.retries, reason,
    });
  }

  return unit;
}

function block(pid, unitId, reason) {
  return _transition(pid, unitId, 'blocked', { block_reason: reason });
}

/**
 * 重置一批 Unit 到 pending(L2 修复后或 L3 增量后)。
 * 允许 passed → pending 和 blocked → pending;其他状态保留(warning log)。
 */
function reset(pid, unitIds) {
  const unitsJson = _loadUnits(pid);
  const reset_units = [];
  const skipped = [];
  for (const id of unitIds) {
    const u = unitsJson.units.find(x => x.id === id);
    if (!u) {
      skipped.push({ id, reason: 'not_found' });
      continue;
    }
    const from = u.status;
    if (!canTransition(from, 'pending')) {
      skipped.push({ id, reason: `cannot_reset_from_${from}` });
      continue;
    }
    u.status = 'pending';
    u.retries = 0;   // 重置 retry counter
    reset_units.push({ id, from });
  }
  _writeBack(pid, unitsJson);

  if (sseBroker && reset_units.length) {
    for (const r of reset_units) {
      sseBroker.emit(pid, 'unit.state', {
        unit_id: r.id, from: r.from, to: 'pending', retries: 0, reason: 'reset',
      });
    }
  }
  return { reset: reset_units, skipped };
}

// ────────────────────────────────────────────────────────────────
// L3 增量 diff
// ────────────────────────────────────────────────────────────────

/**
 * 对比新旧 units.json,返回 added/changed/removed 三类 unit id。
 * - added : 新 list 有,旧 list 无
 * - changed: 双方都有但 contract_hash 不同
 * - removed: 旧 list 有,新 list 无
 */
function diffUnits(oldUnitsList, newUnitsList) {
  const oldMap = new Map((oldUnitsList || []).map(u => [u.id, u]));
  const newMap = new Map((newUnitsList || []).map(u => [u.id, u]));

  const added = [];
  const changed = [];
  const removed = [];

  for (const [id, n] of newMap) {
    if (!oldMap.has(id)) {
      added.push(id);
    } else if (oldMap.get(id).contract_hash !== n.contract_hash) {
      changed.push(id);
    }
  }
  for (const [id] of oldMap) {
    if (!newMap.has(id)) removed.push(id);
  }
  return { added, changed, removed };
}

// ────────────────────────────────────────────────────────────────
// 初始化 units.json 骨架
// ────────────────────────────────────────────────────────────────

/**
 * Architect 第一次生成 SDD 时,构造 units.json 骨架。
 * 各 unit 自带默认 retry_budget=5、status=pending、retries=0。
 */
function buildUnitsJson({ project_id, prd_rev, sdd_rev, units, l2_budget = 3 }) {
  return {
    project_id,
    prd_rev: String(prd_rev),
    sdd_rev: String(sdd_rev),
    l2_rounds: 0,
    l2_budget,
    units: units.map(u => ({
      id: u.id,
      type: u.type,
      status: 'pending',
      depends_on: u.depends_on || [],
      retries: 0,
      retry_budget: u.retry_budget || 5,
      contract_hash: u.contract_hash,
      ...(u.last_run ? { last_run: u.last_run } : {}),
    })),
  };
}

module.exports = {
  // 读
  list, get,
  pending, passed, blocked, failed, anyBlocked,
  l2GateOpen,

  // 写
  markCoding, markTesting, pass, fail, block, reset,

  // L3
  diffUnits,

  // 工具
  buildUnitsJson,
  canTransition,
  TRANSITIONS,

  // 错误
  InvalidTransitionError,

  // 依赖注入
  attachSseBroker,
};