且 data-rev 数字
+ * - 每个 section 必含 +
+ * - PRD 的 data-rev 单调递增(仅检查升序,跳号警告但不报错)
+ *
+ * 不强求 8 类信息覆盖(那是 PM/Architect 自检层的事,不在 DocStore 阻塞)
+ *
+ * @param {string} html
+ * @param {'prd'|'sdd'} kind
+ * @returns {{ ok: boolean, errors: string[] }}
+ */
+function validateHTML(html, kind) {
+ const errors = [];
+ if (typeof html !== 'string' || html.length === 0) {
+ return { ok: false, errors: ['empty html'] };
+ }
+
+ let $;
+ try {
+ $ = cheerio.load(html, { decodeEntities: false });
+ } catch (e) {
+ return { ok: false, errors: [`cheerio parse failed: ${e.message}`] };
+ }
+
+ const article = $(`article[data-doc="${kind}"]`);
+ if (article.length === 0) {
+ errors.push(`missing `);
+ return { ok: false, errors };
+ }
+ if (article.length > 1) {
+ errors.push(`multiple elements`);
+ }
+
+ const articleRev = article.first().attr('data-rev');
+ if (!articleRev || !/^\d+$/.test(articleRev)) {
+ errors.push(`article missing or invalid data-rev: "${articleRev}"`);
+ }
+
+ const sections = article.first().find('section');
+ if (sections.length === 0) {
+ errors.push('article has no ');
+ }
+
+ const revs = [];
+ sections.each((i, el) => {
+ const $sec = $(el);
+ if ($sec.find('h3').length === 0) {
+ errors.push(`section[${i}] missing `);
+ }
+ if ($sec.find('p').length === 0) {
+ errors.push(`section[${i}] missing
`);
+ }
+ const rev = $sec.attr('data-rev');
+ if (rev && /^\d+$/.test(rev)) revs.push(parseInt(rev, 10));
+ });
+
+ // section data-rev 单调递增检查(允许相等,因为可能多 section 同 rev)
+ for (let i = 1; i < revs.length; i++) {
+ if (revs[i] < revs[i - 1]) {
+ errors.push(`data-rev not monotonic at section[${i}]: ${revs[i - 1]} → ${revs[i]}`);
+ }
+ }
+
+ return { ok: errors.length === 0, errors };
+}
+
+/**
+ * 校验 units.json schema(M-2)+ DAG 无环
+ * @returns {{ ok, errors }}
+ */
+function validateUnitsSchema(json) {
+ const errors = [];
+ if (!json || typeof json !== 'object') {
+ return { ok: false, errors: ['units json must be object'] };
+ }
+ for (const k of ['project_id', 'units']) {
+ if (!json[k]) errors.push(`missing field: ${k}`);
+ }
+ if (!Array.isArray(json.units)) {
+ errors.push('units must be array');
+ return { ok: false, errors };
+ }
+
+ const idPattern = /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/;
+ const validStatuses = ['pending', 'coding', 'testing', 'failed', 'passed', 'blocked'];
+ const validTypes = ['be', 'fe', 'mw', 'db'];
+ const seen = new Set();
+
+ for (const u of json.units) {
+ if (!u.id) {
+ errors.push('unit missing id');
+ continue;
+ }
+ if (!idPattern.test(u.id)) {
+ errors.push(`unit id "${u.id}" violates naming pattern`);
+ }
+ if (seen.has(u.id)) {
+ errors.push(`duplicate unit id: ${u.id}`);
+ }
+ seen.add(u.id);
+ if (!validTypes.includes(u.type)) {
+ errors.push(`unit ${u.id} invalid type: ${u.type}`);
+ }
+ if (!validStatuses.includes(u.status)) {
+ errors.push(`unit ${u.id} invalid status: ${u.status}`);
+ }
+ if (!Array.isArray(u.depends_on)) {
+ errors.push(`unit ${u.id} depends_on must be array`);
+ }
+ }
+
+ // 引用完整性 + DAG 无环
+ const ids = new Set(json.units.map(u => u.id));
+ for (const u of json.units) {
+ for (const dep of u.depends_on || []) {
+ if (!ids.has(dep)) {
+ errors.push(`unit ${u.id} depends_on unknown unit: ${dep}`);
+ }
+ }
+ }
+ if (errors.length === 0 && hasCycle(json.units)) {
+ errors.push('units DAG has cycle');
+ }
+
+ return { ok: errors.length === 0, errors };
+}
+
+function hasCycle(units) {
+ const idx = new Map(units.map((u, i) => [u.id, i]));
+ const color = new Array(units.length).fill(0); // 0=white, 1=gray, 2=black
+ let cycle = false;
+
+ function visit(i) {
+ if (cycle) return;
+ if (color[i] === 2) return;
+ if (color[i] === 1) {
+ cycle = true;
+ return;
+ }
+ color[i] = 1;
+ for (const dep of units[i].depends_on || []) {
+ if (idx.has(dep)) visit(idx.get(dep));
+ }
+ color[i] = 2;
+ }
+ for (let i = 0; i < units.length && !cycle; i++) visit(i);
+ return cycle;
+}
+
+// ────────────────────────────────────────────────────────────────
+// PRD / SDD multi-file site
+// ────────────────────────────────────────────────────────────────
+
+/** 列项目下 prd/ 站点内所有 html 章节相对路径 */
+function listPrdFiles(pid) {
+ return _listSiteHtmls(pathResolver.resolvePrdSite(pid));
+}
+
+function listSddFiles(pid) {
+ return _listSiteHtmls(pathResolver.resolveSddSite(pid));
+}
+
+function _listSiteHtmls(siteRoot) {
+ const out = [];
+ if (!fs.existsSync(siteRoot)) return out;
+ function walk(dir, prefix) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ if (e.name === 'assets') continue; // assets 不计入 chapter 列表
+ walk(full, rel);
+ } else if (e.name.endsWith('.html')) {
+ out.push(rel);
+ }
+ }
+ }
+ walk(siteRoot, '');
+ return out.sort();
+}
+
+/** 读单个 PRD/SDD 章节 */
+function readPrdChapter(pid, relPath) {
+ const file = path.join(pathResolver.resolvePrdSite(pid), relPath);
+ if (!fs.existsSync(file)) throw new Error(`PRD chapter not found: ${relPath}`);
+ return fs.readFileSync(file, 'utf8');
+}
+
+function readSddChapter(pid, relPath) {
+ const file = path.join(pathResolver.resolveSddSite(pid), relPath);
+ if (!fs.existsSync(file)) throw new Error(`SDD chapter not found: ${relPath}`);
+ return fs.readFileSync(file, 'utf8');
+}
+
+/**
+ * 写一个 PRD chapter(HTML)。校验通过 + 旧版(如有)归档 + 落盘。
+ * @returns { rev: number, path: string }
+ */
+function writePrdChapter(pid, relPath, html, opts = {}) {
+ return _writeChapter(pid, 'prd', relPath, html, opts);
+}
+
+function writeSddChapter(pid, relPath, html, opts = {}) {
+ return _writeChapter(pid, 'sdd', relPath, html, opts);
+}
+
+function _writeChapter(pid, kind, relPath, html, opts) {
+ const validation = validateHTML(html, kind);
+ if (!validation.ok && !opts.skipValidation) {
+ throw new ValidationError(
+ `${kind}/${relPath} validation failed`, validation.errors);
+ }
+ const siteRoot = kind === 'prd'
+ ? pathResolver.resolvePrdSite(pid)
+ : pathResolver.resolveSddSite(pid);
+ const target = path.join(siteRoot, relPath);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+
+ // 归档旧版(如有)
+ if (fs.existsSync(target)) {
+ archiveFile(pid, kind, relPath, fs.readFileSync(target, 'utf8'));
+ }
+
+ fs.writeFileSync(target, html);
+
+ // 解析 article data-rev 作为新 rev
+ let rev = 1;
+ try {
+ const $ = cheerio.load(html);
+ const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev');
+ if (r && /^\d+$/.test(r)) rev = parseInt(r, 10);
+ } catch { /* ignore */ }
+
+ return { rev, path: target };
+}
+
+/** 把旧文件归档到 history/{kind}-rev-{N}/{relPath} */
+function archiveFile(pid, kind, relPath, oldContent) {
+ // 取旧版的 rev(从 oldContent 解析 article data-rev)
+ let oldRev = 0;
+ try {
+ const $ = cheerio.load(oldContent);
+ const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev');
+ if (r && /^\d+$/.test(r)) oldRev = parseInt(r, 10);
+ } catch { /* ignore */ }
+
+ const archiveDir = path.join(
+ pathResolver.resolveHistoryDir(pid),
+ `${kind}-rev-${oldRev || 'unknown'}`
+ );
+ const archiveTarget = path.join(archiveDir, relPath);
+ fs.mkdirSync(path.dirname(archiveTarget), { recursive: true });
+ fs.writeFileSync(archiveTarget, oldContent);
+}
+
+// ────────────────────────────────────────────────────────────────
+// units.json / integration/spec.json
+// ────────────────────────────────────────────────────────────────
+
+function readUnits(pid) {
+ const p = pathResolver.resolveUnitsJson(pid);
+ if (!fs.existsSync(p)) return null;
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
+}
+
+function writeUnits(pid, json, opts = {}) {
+ const validation = validateUnitsSchema(json);
+ if (!validation.ok && !opts.skipValidation) {
+ throw new ValidationError('units.json validation failed', validation.errors);
+ }
+ const p = pathResolver.resolveUnitsJson(pid);
+
+ // 归档旧版
+ if (fs.existsSync(p)) {
+ const old = JSON.parse(fs.readFileSync(p, 'utf8'));
+ const oldRev = old.sdd_rev || 'unknown';
+ const archivePath = path.join(
+ pathResolver.resolveHistoryDir(pid),
+ `units-rev-${oldRev}.json`
+ );
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
+ fs.writeFileSync(archivePath, JSON.stringify(old, null, 2));
+ }
+
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, JSON.stringify(json, null, 2));
+ return { rev: json.sdd_rev || 1, path: p };
+}
+
+function readIntegrationSpec(pid) {
+ const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json');
+ if (!fs.existsSync(p)) return null;
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
+}
+
+function writeIntegrationSpec(pid, json) {
+ const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json');
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+
+ // 校验 involves ⊆ units.json
+ const units = readUnits(pid);
+ if (units && Array.isArray(json.cases)) {
+ const unitIds = new Set(units.units.map(u => u.id));
+ const errors = [];
+ for (const c of json.cases) {
+ for (const u of c.involves || []) {
+ if (!unitIds.has(u)) {
+ errors.push(`case ${c.id} involves unknown unit: ${u}`);
+ }
+ }
+ }
+ if (errors.length > 0) {
+ throw new ValidationError('integration spec references unknown units', errors);
+ }
+ }
+
+ fs.writeFileSync(p, JSON.stringify(json, null, 2));
+ return { path: p };
+}
+
+// ────────────────────────────────────────────────────────────────
+// Project meta + utilities
+// ────────────────────────────────────────────────────────────────
+
+function readProjectMeta(pid) {
+ const p = pathResolver.resolveProjectJson(pid);
+ if (!fs.existsSync(p)) return null;
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
+}
+
+function writeProjectMeta(pid, meta) {
+ const p = pathResolver.resolveProjectJson(pid);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, JSON.stringify(meta, null, 2));
+ return { path: p };
+}
+
+/**
+ * canonical JSON + sha256 截 16 hex 字符
+ * @param {object} contract
+ * @returns {string} "sha256:..."
+ */
+function hashContract(contract) {
+ if (!contract || typeof contract !== 'object') {
+ throw new Error('hashContract: contract must be object');
+ }
+ const body = contract.exports || contract;
+ const normalized = canonical(body);
+ const full = crypto.createHash('sha256').update(normalized).digest('hex');
+ return 'sha256:' + full.slice(0, 16);
+}
+
+function canonical(v) {
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
+ if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
+ const keys = Object.keys(v).sort();
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
+}
+
+/** 验证某 Unit 的 contract_hash 与重算一致 */
+function verifyContractHash(unit, contract) {
+ const recomputed = hashContract(contract);
+ return {
+ ok: recomputed === unit.contract_hash,
+ expected: unit.contract_hash,
+ actual: recomputed,
+ };
+}
+
+module.exports = {
+ // 校验
+ validateHTML,
+ validateUnitsSchema,
+ ValidationError,
+
+ // PRD/SDD 多页站点
+ listPrdFiles,
+ listSddFiles,
+ readPrdChapter,
+ readSddChapter,
+ writePrdChapter,
+ writeSddChapter,
+
+ // units / integration
+ readUnits,
+ writeUnits,
+ readIntegrationSpec,
+ writeIntegrationSpec,
+
+ // project meta
+ readProjectMeta,
+ writeProjectMeta,
+
+ // utils
+ hashContract,
+ verifyContractHash,
+ canonical,
+ hasCycle,
+};
diff --git a/server/services/devloop/git-vault.js b/server/services/devloop/git-vault.js
new file mode 100644
index 0000000..c7e6f4c
--- /dev/null
+++ b/server/services/devloop/git-vault.js
@@ -0,0 +1,312 @@
+/**
+ * DevLoop · gitVault
+ *
+ * 每项目一个独立 git repo,LoopController 在每次 Agent 成功完成时调
+ * gitVault.commit(...) 落盘一个版本。Agent 自身不动 git。
+ *
+ * - 实现用 child_process.spawn('git', ...);不依赖第三方 npm 包
+ * - 所有方法按 pid 串行化(per-pid mutex),避免同项目并发 git 操作打架
+ * - commit author 标 Agent 名("PM Agent ")
+ * - nothing-to-commit 不抛错,返 { ok: true, skipped: true }
+ *
+ * 规格:docs/prd/newfunction/_spec/arch-git-versioning.md
+ * docs/prd/newfunction/_spec/sdd-control.md · C-18
+ * docs/prd/newfunction/_spec/sdd-process.md · P-10
+ */
+
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const pathResolver = require('./path-resolver');
+
+const DEFAULT_GITIGNORE = `# DevLoop project .gitignore
+
+# 高频写的运行态,不需要版本
+agent-state/
+
+# git 本身就是 history
+history/
+
+# 大日志
+stream.log
+units/*/runs/*/stdout.txt
+units/*/runs/*/stderr.txt
+integration/runs/*/process.log
+integration/runs/*/http-trace.ndjson.gz
+
+# Node / 依赖
+node_modules/
+*.log
+.DS_Store
+.cache/
+.tmp/
+.coverage/
+
+# 测试中间文件
+units/*/.vitest-cache/
+`;
+
+// per-pid mutex(简单 Promise chain)
+const mutexes = new Map();
+function _withLock(pid, fn) {
+ const prev = mutexes.get(pid) || Promise.resolve();
+ const next = prev.then(fn, fn); // 不管前一个成功失败,继续跑下一个
+ mutexes.set(pid, next.catch(() => {})); // 防止 unhandled rejection
+ return next;
+}
+
+// 运行单条 git 命令;返回 { ok, code, stdout, stderr }
+function _run(cwd, args, opts = {}) {
+ return new Promise((resolve) => {
+ const child = spawn('git', args, {
+ cwd,
+ env: { ...process.env, ...(opts.env || {}) },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '', stderr = '';
+ child.stdout.on('data', c => { stdout += c.toString(); });
+ child.stderr.on('data', c => { stderr += c.toString(); });
+ child.on('close', code => {
+ resolve({ ok: code === 0, code, stdout, stderr });
+ });
+ child.on('error', err => {
+ resolve({ ok: false, code: -1, stdout, stderr: err.message });
+ });
+ });
+}
+
+// ────────────────────────────────────────────────────────────────
+// API
+// ────────────────────────────────────────────────────────────────
+
+/** 在项目根 git init + 写 .gitignore + 首个 commit */
+async function init(pid, opts = {}) {
+ return _withLock(pid, async () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+
+ // 已是 repo 则跳
+ if (fs.existsSync(path.join(root, '.git'))) {
+ return { ok: true, head: await _head(root), already_exists: true };
+ }
+
+ // 1. git init
+ const r1 = await _run(root, ['init', '-q', '-b', opts.initialBranch || 'main']);
+ if (!r1.ok) throw new Error(`git init failed: ${r1.stderr}`);
+
+ // 2. 配 author identity(commit 需要)
+ const userName = opts.name || 'DevLoop';
+ const userEmail = opts.email || 'devloop@lemonboss.local';
+ await _run(root, ['config', 'user.name', userName]);
+ await _run(root, ['config', 'user.email', userEmail]);
+ // 不签名(v1 不强求 GPG)
+ await _run(root, ['config', 'commit.gpgsign', 'false']);
+
+ // 3. 写 .gitignore
+ const gitignorePath = path.join(root, '.gitignore');
+ if (!fs.existsSync(gitignorePath)) {
+ fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE);
+ }
+
+ // 4. 首个 commit
+ const r2 = await _run(root, ['add', '.']);
+ if (!r2.ok) throw new Error(`git add failed: ${r2.stderr}`);
+
+ const initialMsg = opts.initialMessage ||
+ `init: project ${pid}` + (opts.summary ? ` · ${opts.summary}` : '');
+ const r3 = await _run(root, ['commit', '-q', '-m', initialMsg, '--allow-empty']);
+ if (!r3.ok) throw new Error(`initial commit failed: ${r3.stderr}`);
+
+ return { ok: true, head: await _head(root) };
+ });
+}
+
+/**
+ * Commit current changes.
+ * @param {string} pid
+ * @param {object} spec
+ * - kind: 'init'|'prd'|'sdd'|'code'|'triage'|'integ'|'ship'|'chore'
+ * - unit_id?: string (code 时填,作为 message 一部分)
+ * - run_id?: string (integ/triage 时填)
+ * - summary: string (必填)
+ * - files?: string[] (限定 add 的路径,默认 git add -A)
+ * - author?: { name, email } (commit author)
+ * - skipEmpty?: boolean (默认 true,nothing-to-commit 返 skipped)
+ * @returns { ok, sha?, skipped? }
+ */
+async function commit(pid, spec) {
+ return _withLock(pid, async () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ if (!fs.existsSync(path.join(root, '.git'))) {
+ throw new Error(`not a git repo: ${root} (call init first)`);
+ }
+ if (!spec.summary) throw new Error('commit: summary required');
+
+ // 1. 加 files(默认 -A,但受 .gitignore 控制)
+ const addArgs = ['add', '--'];
+ if (Array.isArray(spec.files) && spec.files.length) {
+ addArgs.push(...spec.files);
+ } else {
+ // -A:把所有变更(包括删除)加进 index
+ const rA = await _run(root, ['add', '-A']);
+ if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`);
+ }
+
+ // 2. 检查是否有可 commit 变化
+ const status = await _run(root, ['status', '--porcelain']);
+ const dirty = status.stdout.trim().length > 0;
+ if (!dirty) {
+ if (spec.skipEmpty === false) {
+ throw new Error('nothing to commit and skipEmpty=false');
+ }
+ return { ok: true, skipped: true };
+ }
+
+ // 3. commit
+ const env = {};
+ if (spec.author?.name) env.GIT_AUTHOR_NAME = spec.author.name;
+ if (spec.author?.email) env.GIT_AUTHOR_EMAIL = spec.author.email;
+ if (spec.author?.name) env.GIT_COMMITTER_NAME = spec.author.name;
+ if (spec.author?.email) env.GIT_COMMITTER_EMAIL = spec.author.email;
+
+ const r = await _run(root, ['commit', '-q', '-m', spec.summary], { env });
+ if (!r.ok) {
+ // 兜底:检测 "nothing to commit" 文案
+ if (/nothing to commit/i.test(r.stdout + r.stderr)) {
+ return { ok: true, skipped: true };
+ }
+ throw new Error(`git commit failed: ${r.stderr || r.stdout}`);
+ }
+
+ const sha = await _head(root);
+ return { ok: true, sha, kind: spec.kind, summary: spec.summary };
+ });
+}
+
+async function tag(pid, tagName, opts = {}) {
+ return _withLock(pid, async () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const args = ['tag'];
+ if (opts.message) {
+ args.push('-a', tagName, '-m', opts.message);
+ } else {
+ args.push(tagName);
+ }
+ if (opts.sha) args.push(opts.sha);
+ const r = await _run(root, args);
+ if (!r.ok) throw new Error(`git tag failed: ${r.stderr}`);
+ return { ok: true, tag: tagName };
+ });
+}
+
+async function log(pid, opts = {}) {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const args = ['log', '--format=%H%x09%an%x09%ae%x09%at%x09%s'];
+ if (opts.limit) args.push(`-n${opts.limit}`);
+ if (opts.since) args.push(`--since=${opts.since}`);
+ if (opts.grep) args.push(`--grep=${opts.grep}`);
+ const r = await _run(root, args);
+ if (!r.ok) {
+ // 空仓库 (no commits) → 返空
+ if (/does not have any commits/i.test(r.stderr)) return [];
+ throw new Error(`git log failed: ${r.stderr}`);
+ }
+ return r.stdout.trim().split('\n').filter(Boolean).map(line => {
+ const [sha, author_name, author_email, ts, ...rest] = line.split('\t');
+ return {
+ sha, author_name, author_email,
+ ts: parseInt(ts, 10),
+ summary: rest.join('\t'),
+ };
+ });
+}
+
+async function diff(pid, refA, refB, opts = {}) {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const args = ['diff', `${refA}..${refB}`];
+ if (opts.path) args.push('--', opts.path);
+ const r = await _run(root, args);
+ if (!r.ok) throw new Error(`git diff failed: ${r.stderr}`);
+ return r.stdout;
+}
+
+async function show(pid, sha) {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const r = await _run(root, ['show', '--format=%H%x09%an%x09%ae%x09%at%x09%s%n', sha]);
+ if (!r.ok) throw new Error(`git show failed: ${r.stderr}`);
+ return { sha, output: r.stdout };
+}
+
+async function listTags(pid) {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const r = await _run(root, ['tag', '--list', '--sort=-creatordate']);
+ if (!r.ok) throw new Error(`git tag --list failed: ${r.stderr}`);
+ return r.stdout.trim().split('\n').filter(Boolean);
+}
+
+async function status(pid) {
+ const root = pathResolver.resolveProjectRoot(pid);
+ if (!fs.existsSync(path.join(root, '.git'))) {
+ return { dirty: false, files: [], no_repo: true };
+ }
+ const r = await _run(root, ['status', '--porcelain']);
+ if (!r.ok) throw new Error(`git status failed: ${r.stderr}`);
+ const files = r.stdout.trim().split('\n').filter(Boolean).map(l => l.trim().split(/\s+/, 2));
+ return { dirty: files.length > 0, files };
+}
+
+async function resetWorkingTree(pid, ref = 'HEAD') {
+ return _withLock(pid, async () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const r1 = await _run(root, ['reset', '--hard', ref]);
+ if (!r1.ok) throw new Error(`git reset failed: ${r1.stderr}`);
+ const r2 = await _run(root, ['clean', '-fd']);
+ if (!r2.ok) throw new Error(`git clean failed: ${r2.stderr}`);
+ return { ok: true };
+ });
+}
+
+async function revert(pid, sha) {
+ return _withLock(pid, async () => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const r = await _run(root, ['revert', '--no-edit', sha]);
+ if (!r.ok) throw new Error(`git revert failed: ${r.stderr}`);
+ return { ok: true, newSha: await _head(root) };
+ });
+}
+
+async function archive(pid, ref = 'HEAD', format = 'zip') {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const outPath = path.join('/tmp', `${pid}-${ref}.${format}`);
+ // git archive --format=zip -o /tmp/x.zip HEAD
+ const r = await _run(root, ['archive', `--format=${format}`, `-o`, outPath, ref]);
+ if (!r.ok) throw new Error(`git archive failed: ${r.stderr}`);
+ return { path: outPath };
+}
+
+// 工具
+async function _head(root) {
+ const r = await _run(root, ['rev-parse', 'HEAD']);
+ if (!r.ok) return null;
+ return r.stdout.trim();
+}
+
+function _resetMutexesForTesting() {
+ mutexes.clear();
+}
+
+module.exports = {
+ init,
+ commit,
+ tag,
+ log,
+ diff,
+ show,
+ listTags,
+ status,
+ resetWorkingTree,
+ revert,
+ archive,
+ DEFAULT_GITIGNORE,
+ _resetMutexesForTesting,
+};
diff --git a/server/services/devloop/path-resolver.js b/server/services/devloop/path-resolver.js
new file mode 100644
index 0000000..f76d365
--- /dev/null
+++ b/server/services/devloop/path-resolver.js
@@ -0,0 +1,325 @@
+/**
+ * DevLoop · pathResolver
+ *
+ * 所有 DevLoop 产物路径的唯一真源。其他服务(docStore / unitRegistry /
+ * bugTracker / sseBroker / gitVault)都通过这里查路径,绝不硬编码
+ * `data/companies/...`。
+ *
+ * 支持三种 mode(见 arch-project-root.html §②):
+ * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/`
+ * - linked-new : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json
+ * - linked-existing : v2,本文件预留接口但 register 阶段拒收
+ *
+ * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const DATA_DIR = path.resolve(__dirname, '../../../data');
+const LINKED_REGISTRY = path.join(os.homedir(), '.devloop', 'linked-projects.json');
+
+// 系统敏感路径黑名单(不允许作为 linked-new 的 target_path)
+// 注意:os.tmpdir() (e.g. /var/folders/... on macOS, /tmp on Linux) 是允许的,
+// 即使前缀匹配 /var;isSensitivePath 内部会先检查 tmpdir 例外。
+const SENSITIVE_PREFIXES = [
+ '/etc', '/usr', '/bin', '/sbin', '/boot',
+ '/var/log', '/var/lib', '/var/db', '/var/spool', '/var/cache',
+ '/System', '/Library/System', '/private/etc', '/private/var/log',
+ path.join(os.homedir(), '.ssh'),
+ path.join(os.homedir(), '.aws'),
+ path.join(os.homedir(), '.gnupg'),
+ path.join(os.homedir(), '.config'),
+ path.join(os.homedir(), 'Downloads'),
+];
+
+const ALLOWED_EXEMPTIONS = [
+ os.tmpdir(), // /var/folders/... (mac) 或 /tmp (linux)
+ '/tmp',
+];
+
+// 内存缓存:pid → { root_path, mode, company_id }
+let cache = null;
+
+function loadLinkedRegistry() {
+ if (!fs.existsSync(LINKED_REGISTRY)) return {};
+ try {
+ return JSON.parse(fs.readFileSync(LINKED_REGISTRY, 'utf8'));
+ } catch {
+ return {};
+ }
+}
+
+function saveLinkedRegistry(reg) {
+ fs.mkdirSync(path.dirname(LINKED_REGISTRY), { recursive: true });
+ fs.writeFileSync(LINKED_REGISTRY, JSON.stringify(reg, null, 2));
+}
+
+/** 全量扫描 + linked 注册表,构造内存索引。LoopController 启动时调一次。 */
+function init() {
+ cache = new Map();
+
+ // 1. 扫 sandbox 项目(data/companies/*/projects/*/project.json)
+ if (fs.existsSync(DATA_DIR)) {
+ const companiesDir = path.join(DATA_DIR, 'companies');
+ if (fs.existsSync(companiesDir)) {
+ for (const cid of fs.readdirSync(companiesDir)) {
+ const projectsDir = path.join(companiesDir, cid, 'projects');
+ if (!fs.existsSync(projectsDir)) continue;
+ for (const pid of fs.readdirSync(projectsDir)) {
+ const root = path.join(projectsDir, pid);
+ const meta = path.join(root, 'project.json');
+ if (fs.existsSync(meta)) {
+ cache.set(pid, { root_path: root, mode: 'sandbox', company_id: cid });
+ }
+ }
+ }
+ }
+ }
+
+ // 2. 叠加 linked 项目
+ const linked = loadLinkedRegistry();
+ for (const [pid, entry] of Object.entries(linked)) {
+ cache.set(pid, {
+ root_path: entry.root_path,
+ mode: entry.mode,
+ company_id: entry.company_id,
+ });
+ }
+
+ return { count: cache.size };
+}
+
+function ensureInit() {
+ if (cache === null) init();
+}
+
+function resolveProjectRoot(pid) {
+ ensureInit();
+ const entry = cache.get(pid);
+ if (!entry) throw new Error(`Unknown project: ${pid}`);
+ return entry.root_path;
+}
+
+function resolveProjectMeta(pid) {
+ ensureInit();
+ const entry = cache.get(pid);
+ if (!entry) throw new Error(`Unknown project: ${pid}`);
+ return { ...entry };
+}
+
+function findProjectByPath(abs) {
+ ensureInit();
+ for (const [pid, entry] of cache.entries()) {
+ if (entry.root_path === abs) return { pid, ...entry };
+ }
+ return null;
+}
+
+// ── 派生路径 ──
+
+function resolveUnitDir(pid, unitId) {
+ return path.join(resolveProjectRoot(pid), 'units', unitId);
+}
+
+function resolvePrdSite(pid) {
+ return path.join(resolveProjectRoot(pid), 'prd');
+}
+
+function resolveSddSite(pid) {
+ return path.join(resolveProjectRoot(pid), 'sdd');
+}
+
+function resolveAgentState(pid, agent) {
+ return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}.json`);
+}
+
+function resolveAgentTodolist(pid, agent) {
+ return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}-todo.json`);
+}
+
+function resolveAgentSnapshotDir(pid) {
+ return path.join(resolveProjectRoot(pid), 'agent-state', 'snapshots');
+}
+
+function resolveTimelineLog(pid) {
+ return path.join(resolveProjectRoot(pid), 'timeline.ndjson');
+}
+
+function resolveBugsLog(pid) {
+ return path.join(resolveProjectRoot(pid), 'bugs.ndjson');
+}
+
+function resolveChangelog(pid) {
+ return path.join(resolveProjectRoot(pid), 'changelog.ndjson');
+}
+
+function resolveStreamLog(pid) {
+ return path.join(resolveProjectRoot(pid), 'stream.log');
+}
+
+function resolveHistoryDir(pid) {
+ return path.join(resolveProjectRoot(pid), 'history');
+}
+
+function resolveIntegrationDir(pid) {
+ return path.join(resolveProjectRoot(pid), 'integration');
+}
+
+function resolveUnitsJson(pid) {
+ return path.join(resolveProjectRoot(pid), 'units.json');
+}
+
+function resolveProjectJson(pid) {
+ return path.join(resolveProjectRoot(pid), 'project.json');
+}
+
+/** Memory 永远在 company 级(不跟项目根走,详见 arch-project-root §⑧) */
+function resolveMemoryDir(companyId, agent) {
+ return path.join(DATA_DIR, 'companies', companyId, 'memory', 'devloop', agent);
+}
+
+function resolveCompanyDir(companyId) {
+ return path.join(DATA_DIR, 'companies', companyId);
+}
+
+// ── 创建项目 ──
+
+function generatePid() {
+ // p- + 6 位 base36
+ const rand = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
+ return `p-${rand}`;
+}
+
+function isSensitivePath(abs) {
+ const normalized = path.resolve(abs);
+ // 1. 明确允许的目录(tmpdir 等)→ 即使前缀匹配也放行
+ for (const ex of ALLOWED_EXEMPTIONS) {
+ const e = path.resolve(ex);
+ if (normalized === e || normalized.startsWith(e + path.sep)) return false;
+ }
+ // 2. 不允许直接放在根 /
+ if (normalized === '/') return true;
+ // 3. 检查黑名单前缀
+ return SENSITIVE_PREFIXES.some(prefix => {
+ const p = path.resolve(prefix);
+ return normalized === p || normalized.startsWith(p + path.sep);
+ });
+}
+
+/**
+ * 在文件系统上分配一个 root_path,但不写任何业务文件。
+ * 仅 mkdir + 校验路径合法。
+ * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。
+ *
+ * @returns { pid, root_path, mode }
+ */
+function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) {
+ if (!company_id) throw new Error('company_id required');
+
+ const pid = generatePid();
+
+ let root_path;
+ if (mode === 'sandbox') {
+ root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid);
+ fs.mkdirSync(root_path, { recursive: true });
+ } else if (mode === 'linked-new') {
+ if (!target_path) throw new Error('target_path required for linked-new');
+ if (!path.isAbsolute(target_path)) {
+ throw new Error('target_path must be absolute');
+ }
+ if (isSensitivePath(target_path)) {
+ const e = new Error(`sensitive_path: ${target_path} is in blacklist`);
+ e.code = 'SENSITIVE_PATH';
+ throw e;
+ }
+ // 父目录必须存在
+ const parent = path.dirname(target_path);
+ if (!fs.existsSync(parent)) {
+ throw new Error(`parent dir does not exist: ${parent}`);
+ }
+ // 必须不存在或为空
+ if (fs.existsSync(target_path)) {
+ const entries = fs.readdirSync(target_path);
+ if (entries.length > 0) {
+ throw new Error(`target_path is not empty: ${target_path}`);
+ }
+ } else {
+ fs.mkdirSync(target_path, { recursive: false });
+ }
+ root_path = path.resolve(target_path);
+ // 注册到 linked-projects
+ const reg = loadLinkedRegistry();
+ reg[pid] = {
+ root_path,
+ mode: 'linked-new',
+ company_id,
+ registered_at: new Date().toISOString(),
+ };
+ saveLinkedRegistry(reg);
+ } else if (mode === 'linked-existing') {
+ const e = new Error('linked-existing mode is v2; not supported in v1');
+ e.code = 'NOT_IMPLEMENTED';
+ throw e;
+ } else {
+ throw new Error(`unknown mode: ${mode}`);
+ }
+
+ // 立即注入缓存
+ ensureInit();
+ cache.set(pid, { root_path, mode, company_id });
+
+ return { pid, root_path, mode };
+}
+
+function unregisterLinkedProject(pid) {
+ const reg = loadLinkedRegistry();
+ if (reg[pid]) {
+ delete reg[pid];
+ saveLinkedRegistry(reg);
+ }
+ if (cache) cache.delete(pid);
+}
+
+function listProjects() {
+ ensureInit();
+ return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry }));
+}
+
+/** 测试用:重置缓存,强制下次重新扫描 */
+function _resetForTesting() {
+ cache = null;
+}
+
+module.exports = {
+ init,
+ resolveProjectRoot,
+ resolveProjectMeta,
+ findProjectByPath,
+ resolveUnitDir,
+ resolvePrdSite,
+ resolveSddSite,
+ resolveAgentState,
+ resolveAgentTodolist,
+ resolveAgentSnapshotDir,
+ resolveTimelineLog,
+ resolveBugsLog,
+ resolveChangelog,
+ resolveStreamLog,
+ resolveHistoryDir,
+ resolveIntegrationDir,
+ resolveUnitsJson,
+ resolveProjectJson,
+ resolveMemoryDir,
+ resolveCompanyDir,
+ allocateProjectRoot,
+ unregisterLinkedProject,
+ listProjects,
+ isSensitivePath,
+ _resetForTesting,
+ // 常量
+ DATA_DIR,
+ LINKED_REGISTRY,
+ SENSITIVE_PREFIXES,
+};
diff --git a/server/services/devloop/sse-broker.js b/server/services/devloop/sse-broker.js
new file mode 100644
index 0000000..08e7a42
--- /dev/null
+++ b/server/services/devloop/sse-broker.js
@@ -0,0 +1,156 @@
+/**
+ * DevLoop · sseBroker
+ *
+ * 每项目一个广播通道,多订阅者。
+ * - subscribe(pid, res, opts) 把 HTTP res 注册为订阅者
+ * - emit(pid, event, data) 写一行到 stream.log + 广播给所有订阅者
+ * - 心跳:每 25s 一条 `:keepalive\n\n` 注释
+ * - 断线重连:客户端发 Last-Event-ID header,replay 从 stream.log 读 > 该 id 的行
+ *
+ * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-11
+ * docs/prd/newfunction/_spec/api-sse.md
+ * docs/prd/newfunction/_spec/sdd-process.md · P-5
+ */
+
+const fs = require('fs');
+const path = require('path');
+const pathResolver = require('./path-resolver');
+
+const HEARTBEAT_MS = 25_000;
+
+// pid → Set<{ res, heartbeatTimer }>
+const subscriptions = new Map();
+
+// 单调递增的事件 id(跨进程不严格保证;崩溃重启从 stream.log 读最大值续)
+let eventCounter = 0;
+
+function _streamLogPath(pid) {
+ return pathResolver.resolveStreamLog(pid);
+}
+
+function _initCounterFromLog(pid) {
+ // 启动时如果 stream.log 已存在,找最大 id 续号
+ const p = _streamLogPath(pid);
+ if (!fs.existsSync(p)) return;
+ const raw = fs.readFileSync(p, 'utf8');
+ const matches = raw.match(/^id: (\d+)$/gm) || [];
+ for (const m of matches) {
+ const n = parseInt(m.slice(4), 10);
+ if (n > eventCounter) eventCounter = n;
+ }
+}
+
+function _ensureCounterInit(pid) {
+ // 全局单调即可;不严格 per-pid(避免每次启动都全扫)
+ if (eventCounter === 0) _initCounterFromLog(pid);
+}
+
+/**
+ * @param {string} pid
+ * @param {http.ServerResponse} res - 调用方应已 writeHead(200, SSE headers)
+ * @param {object} opts - { replayFromOffset?: string | number (Last-Event-ID) }
+ */
+function subscribe(pid, res, opts = {}) {
+ _ensureCounterInit(pid);
+
+ // Replay:从 stream.log 读 > replayFromOffset 的行
+ if (opts.replayFromOffset != null) {
+ const fromId = parseInt(opts.replayFromOffset, 10) || 0;
+ _replayTo(res, pid, fromId);
+ }
+
+ // 心跳
+ const heartbeat = setInterval(() => {
+ try { res.write(':keepalive\n\n'); } catch { /* ignore broken pipe */ }
+ }, HEARTBEAT_MS);
+
+ // 加订阅
+ if (!subscriptions.has(pid)) subscriptions.set(pid, new Set());
+ const entry = { res, heartbeatTimer: heartbeat };
+ subscriptions.get(pid).add(entry);
+
+ // 客户端断开
+ res.on('close', () => {
+ clearInterval(heartbeat);
+ subscriptions.get(pid)?.delete(entry);
+ if (subscriptions.get(pid)?.size === 0) subscriptions.delete(pid);
+ });
+}
+
+function _replayTo(res, pid, fromId) {
+ const p = _streamLogPath(pid);
+ if (!fs.existsSync(p)) return;
+
+ const raw = fs.readFileSync(p, 'utf8');
+ // stream.log 格式为 SSE 帧:`id: N\nevent: X\ndata: {...}\n\n`
+ const blocks = raw.split('\n\n').filter(Boolean);
+ for (const block of blocks) {
+ const idMatch = block.match(/^id: (\d+)/m);
+ if (!idMatch) continue;
+ const id = parseInt(idMatch[1], 10);
+ if (id <= fromId) continue;
+ try { res.write(block + '\n\n'); } catch { return; }
+ }
+}
+
+/**
+ * 广播一条事件 + 落盘 stream.log。
+ * @param {string} pid
+ * @param {string} event - 事件名,如 "unit.state" / "bug.opened"
+ * @param {any} data
+ */
+function emit(pid, event, data) {
+ _ensureCounterInit(pid);
+ const id = ++eventCounter;
+ const payload = JSON.stringify(data == null ? {} : data);
+ const line = `id: ${id}\nevent: ${event}\ndata: ${payload}\n\n`;
+
+ // 1. 落盘
+ const p = _streamLogPath(pid);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.appendFileSync(p, line);
+
+ // 2. 广播
+ const subs = subscriptions.get(pid);
+ if (subs) {
+ for (const entry of subs) {
+ try { entry.res.write(line); } catch { /* ignore broken pipe */ }
+ }
+ }
+
+ return { id, event };
+}
+
+/** 项目结束:发 close + 关所有订阅。 */
+function close(pid) {
+ const subs = subscriptions.get(pid);
+ if (!subs) return;
+ for (const entry of subs) {
+ try { entry.res.write('event: close\ndata: {}\n\n'); } catch { /* ignore */ }
+ try { entry.res.end(); } catch { /* ignore */ }
+ clearInterval(entry.heartbeatTimer);
+ }
+ subscriptions.delete(pid);
+}
+
+/** 测试用 */
+function _resetForTesting() {
+ for (const subs of subscriptions.values()) {
+ for (const entry of subs) clearInterval(entry.heartbeatTimer);
+ }
+ subscriptions.clear();
+ eventCounter = 0;
+}
+
+function _activeSubscribers(pid) {
+ return subscriptions.get(pid)?.size || 0;
+}
+
+module.exports = {
+ subscribe,
+ emit,
+ close,
+ HEARTBEAT_MS,
+ _resetForTesting,
+ _activeSubscribers,
+};
diff --git a/server/services/devloop/timeline-logger.js b/server/services/devloop/timeline-logger.js
new file mode 100644
index 0000000..8b4b775
--- /dev/null
+++ b/server/services/devloop/timeline-logger.js
@@ -0,0 +1,100 @@
+/**
+ * DevLoop · timelineLogger
+ *
+ * 全流程统一事件日志(M-13 ProcessTimeline)。append-only NDJSON。
+ * - 每行一个事件
+ * - 每条 ≤ 1KB(summary 自动截 280 字符;payload > 4KB 强制 ref 形式)
+ * - 同步原子写(appendFileSync)
+ *
+ * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-15
+ * docs/prd/newfunction/_spec/sdd-model.md · M-13
+ */
+
+const fs = require('fs');
+const path = require('path');
+const readline = require('readline');
+
+const pathResolver = require('./path-resolver');
+
+const SUMMARY_MAX = 280;
+const PAYLOAD_MAX_BYTES = 4096;
+
+function _logPath(pid) { return pathResolver.resolveTimelineLog(pid); }
+
+function _truncate(s, n) {
+ if (typeof s !== 'string') s = String(s);
+ if (s.length <= n) return s;
+ return s.slice(0, n - 1) + '…';
+}
+
+/**
+ * Append 一条事件。同步写。
+ * @param {string} pid
+ * @param {object} ev - { source, kind, ref?, summary, payload? }
+ */
+function append(pid, ev) {
+ if (!ev || !ev.source || !ev.kind || !ev.summary) {
+ throw new Error('timelineLogger.append: source/kind/summary required');
+ }
+
+ const event = {
+ ts: Math.floor(Date.now() / 1000),
+ source: ev.source,
+ kind: ev.kind,
+ ref: ev.ref || null,
+ summary: _truncate(ev.summary, SUMMARY_MAX),
+ };
+
+ // payload 超大 → 强制改 ref 形式(不写盘外部文件,仅写入 ref 占位)
+ if (ev.payload !== undefined) {
+ const json = JSON.stringify(ev.payload);
+ if (json.length > PAYLOAD_MAX_BYTES) {
+ event.payload = { oversize: true, original_bytes: json.length };
+ } else {
+ event.payload = ev.payload;
+ }
+ }
+
+ const line = JSON.stringify(event) + '\n';
+ const p = _logPath(pid);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.appendFileSync(p, line);
+ return event;
+}
+
+/**
+ * 读全部 / 过滤事件。
+ * @param {object} opts - { limit?, since?: unix ts, source?, kind? }
+ */
+function read(pid, opts = {}) {
+ const p = _logPath(pid);
+ if (!fs.existsSync(p)) return [];
+
+ const limit = opts.limit || 100;
+ const since = opts.since;
+ const filterSource = opts.source;
+ const filterKind = opts.kind;
+
+ const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
+ const events = [];
+
+ // 倒序读,遇到 limit 停(避免 load 整个 file)
+ for (let i = lines.length - 1; i >= 0; i--) {
+ let ev;
+ try { ev = JSON.parse(lines[i]); } catch { continue; }
+ if (since && ev.ts < since) break; // 由于 ts 单调,可以提前 break
+ if (filterSource && ev.source !== filterSource) continue;
+ if (filterKind && ev.kind !== filterKind) continue;
+ events.push(ev);
+ if (events.length >= limit) break;
+ }
+
+ return events.reverse(); // 输出按时间正序
+}
+
+/** 最近 N 条(已按时间正序) */
+function tail(pid, n = 10) {
+ return read(pid, { limit: n });
+}
+
+module.exports = { append, read, tail };
diff --git a/server/services/devloop/unit-registry.js b/server/services/devloop/unit-registry.js
new file mode 100644
index 0000000..616a1e4
--- /dev/null
+++ b/server/services/devloop/unit-registry.js
@@ -0,0 +1,279 @@
+/**
+ * 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,
+};
diff --git a/server/tests/devloop/bug-tracker.test.js b/server/tests/devloop/bug-tracker.test.js
new file mode 100644
index 0000000..078ba90
--- /dev/null
+++ b/server/tests/devloop/bug-tracker.test.js
@@ -0,0 +1,105 @@
+/**
+ * bugTracker 单测
+ */
+const fs = require('fs');
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const bugTracker = require('../../services/devloop/bug-tracker');
+
+describe('bugTracker', () => {
+ let pid, root;
+
+ beforeEach(() => {
+ pathResolver._resetForTesting();
+ const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
+ pid = r.pid;
+ root = r.root_path;
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ describe('open + dedup', () => {
+ it('open new bug returns id', () => {
+ const b = bugTracker.open(pid, {
+ source: 'unit_test', unit_id: 'auth-be',
+ test_name: 'login.invalid-pwd', severity: 'high',
+ title: 'expected 401 got 500',
+ });
+ expect(b.id).toMatch(/^bug-/);
+ expect(b.status).toBe('open');
+ });
+
+ it('same unit + test → dedup returns existing id', () => {
+ const b1 = bugTracker.open(pid, {
+ unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T1',
+ });
+ const b2 = bugTracker.open(pid, {
+ unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T2',
+ });
+ expect(b2.id).toBe(b1.id);
+ });
+ });
+
+ describe('transition', () => {
+ it('open → fixing → fixed → verified', () => {
+ const b = bugTracker.open(pid, {
+ unit_id: 'auth-be', test_name: 'x', title: 'T',
+ });
+ bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' });
+ expect(bugTracker.get(pid, b.id).status).toBe('fixing');
+ bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' });
+ expect(bugTracker.get(pid, b.id).status).toBe('fixed');
+ bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' });
+ expect(bugTracker.get(pid, b.id).status).toBe('verified');
+ });
+
+ it('open → verified (skip) throws', () => {
+ const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' });
+ expect(() => bugTracker.transition(pid, b.id, { to: 'verified', by: 'system' }))
+ .toThrow(bugTracker.InvalidBugTransitionError);
+ });
+
+ it('verified → open (reopen) ok', () => {
+ const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' });
+ bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' });
+ bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' });
+ bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' });
+ bugTracker.transition(pid, b.id, { to: 'open', by: 'system' });
+ expect(bugTracker.get(pid, b.id).status).toBe('open');
+ });
+ });
+
+ describe('list + stats', () => {
+ it('filter by status', () => {
+ const b1 = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T1' });
+ const b2 = bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T2' });
+ bugTracker.transition(pid, b2.id, { to: 'fixing', by: 'coder' });
+ const open = bugTracker.list(pid, { status: ['open'] });
+ const fixing = bugTracker.list(pid, { status: ['fixing'] });
+ expect(open.map(b => b.id)).toContain(b1.id);
+ expect(fixing.map(b => b.id)).toContain(b2.id);
+ });
+
+ it('stats counts by status', () => {
+ bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T', severity: 'high' });
+ bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T', severity: 'low' });
+ const s = bugTracker.stats(pid);
+ expect(s.open).toBe(2);
+ expect(s.by_severity.high).toBe(1);
+ expect(s.by_severity.low).toBe(1);
+ });
+ });
+
+ describe('history', () => {
+ it('tracks all transitions', () => {
+ const b = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't', title: 'T' });
+ bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' });
+ bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' });
+ const h = bugTracker.history(pid, b.id);
+ // 含 open 时的 history (1) + 2 次 transition = 3 条
+ expect(h.length).toBeGreaterThanOrEqual(3);
+ });
+ });
+});
diff --git a/server/tests/devloop/doc-store.test.js b/server/tests/devloop/doc-store.test.js
new file mode 100644
index 0000000..18c1cb1
--- /dev/null
+++ b/server/tests/devloop/doc-store.test.js
@@ -0,0 +1,128 @@
+/**
+ * docStore 单测
+ */
+const docStore = require('../../services/devloop/doc-store');
+
+describe('docStore', () => {
+ describe('validateHTML', () => {
+ it('rejects empty', () => {
+ const r = docStore.validateHTML('', 'prd');
+ expect(r.ok).toBe(false);
+ });
+
+ it('rejects missing article', () => {
+ const r = docStore.validateHTML('hi
', 'prd');
+ expect(r.ok).toBe(false);
+ expect(r.errors.join(' ')).toContain('article');
+ });
+
+ it('rejects wrong doc kind', () => {
+ const r = docStore.validateHTML('', 'prd');
+ expect(r.ok).toBe(false);
+ });
+
+ it('accepts valid PRD chapter', () => {
+ const html = `
+
+
+ `;
+ const r = docStore.validateHTML(html, 'prd');
+ expect(r.ok).toBe(true);
+ });
+
+ it('detects section without h3', () => {
+ const html = `
+
+ `;
+ const r = docStore.validateHTML(html, 'prd');
+ expect(r.ok).toBe(false);
+ expect(r.errors.join(' ')).toContain('h3');
+ });
+
+ it('detects non-monotonic data-rev', () => {
+ const html = `
+
+
+ `;
+ const r = docStore.validateHTML(html, 'prd');
+ expect(r.ok).toBe(false);
+ expect(r.errors.join(' ')).toContain('monotonic');
+ });
+ });
+
+ describe('validateUnitsSchema', () => {
+ const validUnit = {
+ id: 'auth-be', type: 'be', status: 'pending',
+ depends_on: [], retries: 0, retry_budget: 5,
+ contract_hash: 'sha256:abc',
+ };
+
+ it('accepts valid units.json', () => {
+ const json = {
+ project_id: 'p-test', prd_rev: '1', sdd_rev: '1',
+ units: [validUnit], l2_rounds: 0, l2_budget: 3,
+ };
+ const r = docStore.validateUnitsSchema(json);
+ expect(r.ok).toBe(true);
+ });
+
+ it('rejects invalid unit id', () => {
+ const json = {
+ project_id: 'p', units: [{ ...validUnit, id: 'AuthBE' }],
+ };
+ const r = docStore.validateUnitsSchema(json);
+ expect(r.ok).toBe(false);
+ expect(r.errors.join(' ')).toContain('naming');
+ });
+
+ it('rejects duplicate ids', () => {
+ const json = {
+ project_id: 'p', units: [validUnit, validUnit],
+ };
+ const r = docStore.validateUnitsSchema(json);
+ expect(r.ok).toBe(false);
+ expect(r.errors.join(' ')).toContain('duplicate');
+ });
+
+ it('rejects unknown depends_on', () => {
+ const json = {
+ project_id: 'p',
+ units: [{ ...validUnit, depends_on: ['ghost-be'] }],
+ };
+ const r = docStore.validateUnitsSchema(json);
+ expect(r.ok).toBe(false);
+ expect(r.errors.join(' ')).toContain('unknown unit');
+ });
+
+ it('detects cycle', () => {
+ const a = { id: 'a-be', type: 'be', status: 'pending', depends_on: ['b-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:1' };
+ const b = { id: 'b-be', type: 'be', status: 'pending', depends_on: ['a-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:2' };
+ const json = { project_id: 'p', units: [a, b] };
+ const r = docStore.validateUnitsSchema(json);
+ expect(r.ok).toBe(false);
+ expect(r.errors.join(' ')).toContain('cycle');
+ });
+ });
+
+ describe('hashContract', () => {
+ it('deterministic for same content', () => {
+ const c = { unit_id: 'x', exports: { http: [{ path: '/x' }] } };
+ const h1 = docStore.hashContract(c);
+ const h2 = docStore.hashContract(c);
+ expect(h1).toBe(h2);
+ expect(h1).toMatch(/^sha256:[a-f0-9]{16}$/);
+ });
+
+ it('different content → different hash', () => {
+ const c1 = { unit_id: 'x', exports: { http: [{ path: '/x' }] } };
+ const c2 = { unit_id: 'x', exports: { http: [{ path: '/y' }] } };
+ expect(docStore.hashContract(c1)).not.toBe(docStore.hashContract(c2));
+ });
+
+ it('canonical: key ordering does not affect hash', () => {
+ const c1 = { exports: { http: [{ a: 1, b: 2 }] } };
+ const c2 = { exports: { http: [{ b: 2, a: 1 }] } };
+ expect(docStore.hashContract(c1)).toBe(docStore.hashContract(c2));
+ });
+ });
+});
diff --git a/server/tests/devloop/git-vault.test.js b/server/tests/devloop/git-vault.test.js
new file mode 100644
index 0000000..0b9a2f7
--- /dev/null
+++ b/server/tests/devloop/git-vault.test.js
@@ -0,0 +1,110 @@
+/**
+ * gitVault 单测
+ *
+ * 需要系统装了 git。如果没装则 skip 所有用例。
+ */
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const gitVault = require('../../services/devloop/git-vault');
+const docStore = require('../../services/devloop/doc-store');
+
+let gitAvailable = false;
+try { execSync('git --version', { stdio: 'ignore' }); gitAvailable = true; } catch { /* skip */ }
+
+(gitAvailable ? describe : describe.skip)('gitVault', () => {
+ let pid, root;
+
+ beforeEach(() => {
+ pathResolver._resetForTesting();
+ gitVault._resetMutexesForTesting();
+ const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
+ pid = r.pid;
+ root = r.root_path;
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('init creates .git + initial commit', async () => {
+ const r = await gitVault.init(pid);
+ expect(r.ok).toBe(true);
+ expect(fs.existsSync(path.join(root, '.git'))).toBe(true);
+ expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(true);
+
+ const logs = await gitVault.log(pid);
+ expect(logs.length).toBeGreaterThanOrEqual(1);
+ expect(logs[0].summary).toMatch(/^init: project p-/);
+ });
+
+ it('init is idempotent', async () => {
+ await gitVault.init(pid);
+ const r = await gitVault.init(pid);
+ expect(r.already_exists).toBe(true);
+ });
+
+ it('commit with content creates new sha', async () => {
+ await gitVault.init(pid);
+ // 写一个真实的文件再 commit (PRD/HTML 校验严格,这里直接 raw)
+ fs.writeFileSync(path.join(root, 'README.md'), 'hello\n');
+ const r = await gitVault.commit(pid, {
+ kind: 'chore', summary: 'add README',
+ author: { name: 'PM Agent', email: 'pm@devloop.local' },
+ });
+ expect(r.ok).toBe(true);
+ expect(r.skipped).toBeFalsy();
+ expect(r.sha).toMatch(/^[a-f0-9]{40}$/);
+
+ // author 被记录
+ const logs = await gitVault.log(pid, { limit: 1 });
+ expect(logs[0].author_name).toBe('PM Agent');
+ expect(logs[0].author_email).toBe('pm@devloop.local');
+ });
+
+ it('commit with nothing-to-commit returns skipped', async () => {
+ await gitVault.init(pid);
+ const r = await gitVault.commit(pid, {
+ kind: 'chore', summary: 'empty commit attempt',
+ });
+ expect(r.ok).toBe(true);
+ expect(r.skipped).toBe(true);
+ });
+
+ it('tag + listTags', async () => {
+ await gitVault.init(pid);
+ await gitVault.tag(pid, 'shipped-v1');
+ const tags = await gitVault.listTags(pid);
+ expect(tags).toContain('shipped-v1');
+ });
+
+ it('status detects dirty tree', async () => {
+ await gitVault.init(pid);
+ let s = await gitVault.status(pid);
+ expect(s.dirty).toBe(false);
+
+ fs.writeFileSync(path.join(root, 'untracked.txt'), 'x');
+ s = await gitVault.status(pid);
+ expect(s.dirty).toBe(true);
+ });
+
+ it('resetWorkingTree clears dirty', async () => {
+ await gitVault.init(pid);
+ fs.writeFileSync(path.join(root, 'dirt.txt'), 'x');
+ expect((await gitVault.status(pid)).dirty).toBe(true);
+
+ await gitVault.resetWorkingTree(pid);
+ expect((await gitVault.status(pid)).dirty).toBe(false);
+ });
+
+ it('archive produces zip', async () => {
+ await gitVault.init(pid);
+ fs.writeFileSync(path.join(root, 'kept.txt'), 'x');
+ await gitVault.commit(pid, { kind: 'chore', summary: 'add file' });
+ const r = await gitVault.archive(pid);
+ expect(fs.existsSync(r.path)).toBe(true);
+ fs.rmSync(r.path);
+ });
+});
diff --git a/server/tests/devloop/path-resolver.test.js b/server/tests/devloop/path-resolver.test.js
new file mode 100644
index 0000000..b5e74db
--- /dev/null
+++ b/server/tests/devloop/path-resolver.test.js
@@ -0,0 +1,149 @@
+/**
+ * pathResolver 单测
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const pathResolver = require('../../services/devloop/path-resolver');
+
+// 用 tmpdir 隔离测试
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-test-'));
+process.env.HOME = TEST_HOME; // 让 LINKED_REGISTRY 写到 tmp
+
+describe('pathResolver', () => {
+ beforeEach(() => {
+ pathResolver._resetForTesting();
+ });
+
+ describe('isSensitivePath', () => {
+ it('rejects /etc', () => {
+ expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true);
+ });
+ it('rejects /usr/local/bin', () => {
+ expect(pathResolver.isSensitivePath('/usr/local/bin')).toBe(true);
+ });
+ it('rejects /etc/passwd absolute', () => {
+ expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true);
+ });
+ it('rejects /var/log/something', () => {
+ expect(pathResolver.isSensitivePath('/var/log/anything')).toBe(true);
+ });
+ it('accepts ~/code/project', () => {
+ expect(pathResolver.isSensitivePath(path.join(os.homedir(), 'code', 'project'))).toBe(false);
+ });
+ it('accepts /tmp/anything', () => {
+ expect(pathResolver.isSensitivePath('/tmp/anything')).toBe(false);
+ });
+ });
+
+ describe('allocateProjectRoot · sandbox', () => {
+ it('creates root under data/companies//projects/', () => {
+ const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
+ expect(r.pid).toMatch(/^p-[a-z0-9]{6}$/);
+ expect(r.mode).toBe('sandbox');
+ expect(r.root_path).toContain('data/companies/c-test/projects/');
+ expect(fs.existsSync(r.root_path)).toBe(true);
+ // cleanup
+ fs.rmSync(r.root_path, { recursive: true, force: true });
+ });
+ });
+
+ describe('allocateProjectRoot · linked-new', () => {
+ it('rejects when target_path missing', () => {
+ expect(() =>
+ pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new' })
+ ).toThrow(/target_path required/);
+ });
+
+ it('rejects relative target_path', () => {
+ expect(() =>
+ pathResolver.allocateProjectRoot({
+ company_id: 'c-test', mode: 'linked-new', target_path: 'relative/path',
+ })
+ ).toThrow(/must be absolute/);
+ });
+
+ it('rejects sensitive path', () => {
+ expect(() =>
+ pathResolver.allocateProjectRoot({
+ company_id: 'c-test', mode: 'linked-new', target_path: '/etc/foo',
+ })
+ ).toThrow(/sensitive_path/);
+ });
+
+ it('rejects when parent does not exist', () => {
+ expect(() =>
+ pathResolver.allocateProjectRoot({
+ company_id: 'c-test', mode: 'linked-new',
+ target_path: '/tmp/devloop-test-nonexistent-parent/proj',
+ })
+ ).toThrow(/parent dir does not exist/);
+ });
+
+ it('rejects when target_path is non-empty dir', () => {
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-'));
+ fs.writeFileSync(path.join(target, 'existing.txt'), 'x');
+ expect(() =>
+ pathResolver.allocateProjectRoot({
+ company_id: 'c-test', mode: 'linked-new', target_path: target,
+ })
+ ).toThrow(/not empty/);
+ fs.rmSync(target, { recursive: true, force: true });
+ });
+
+ it('accepts empty existing dir', () => {
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-'));
+ const r = pathResolver.allocateProjectRoot({
+ company_id: 'c-test', mode: 'linked-new', target_path: target,
+ });
+ expect(r.mode).toBe('linked-new');
+ expect(r.root_path).toBe(target);
+ // 检查 linked-registry
+ const reg = JSON.parse(fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8'));
+ expect(reg[r.pid]).toBeDefined();
+ expect(reg[r.pid].mode).toBe('linked-new');
+
+ fs.rmSync(target, { recursive: true, force: true });
+ pathResolver.unregisterLinkedProject(r.pid);
+ });
+ });
+
+ describe('allocateProjectRoot · linked-existing rejected in v1', () => {
+ it('throws NOT_IMPLEMENTED', () => {
+ expect(() =>
+ pathResolver.allocateProjectRoot({
+ company_id: 'c-test', mode: 'linked-existing', target_path: '/tmp',
+ })
+ ).toThrow(/v2/);
+ });
+ });
+
+ describe('resolveProjectRoot + 派生路径', () => {
+ it('throws on unknown pid', () => {
+ expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/);
+ });
+
+ it('after allocate, all resolvers work', () => {
+ const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
+ expect(pathResolver.resolveProjectRoot(r.pid)).toBe(r.root_path);
+ expect(pathResolver.resolveUnitDir(r.pid, 'auth-be'))
+ .toBe(path.join(r.root_path, 'units', 'auth-be'));
+ expect(pathResolver.resolvePrdSite(r.pid)).toBe(path.join(r.root_path, 'prd'));
+ expect(pathResolver.resolveSddSite(r.pid)).toBe(path.join(r.root_path, 'sdd'));
+ expect(pathResolver.resolveTimelineLog(r.pid)).toBe(path.join(r.root_path, 'timeline.ndjson'));
+ expect(pathResolver.resolveBugsLog(r.pid)).toBe(path.join(r.root_path, 'bugs.ndjson'));
+ expect(pathResolver.resolveAgentState(r.pid, 'pm'))
+ .toBe(path.join(r.root_path, 'agent-state', 'pm.json'));
+ fs.rmSync(r.root_path, { recursive: true, force: true });
+ });
+ });
+
+ describe('memory dir 在 company 级', () => {
+ it('does not depend on pid', () => {
+ const m1 = pathResolver.resolveMemoryDir('c-test', 'pm');
+ expect(m1).toContain('companies/c-test/memory/devloop/pm');
+ expect(m1).not.toContain('projects/');
+ });
+ });
+});
diff --git a/server/tests/devloop/timeline-logger.test.js b/server/tests/devloop/timeline-logger.test.js
new file mode 100644
index 0000000..ce34483
--- /dev/null
+++ b/server/tests/devloop/timeline-logger.test.js
@@ -0,0 +1,115 @@
+/**
+ * timelineLogger + changelogWriter 单测
+ */
+const fs = require('fs');
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const timelineLogger = require('../../services/devloop/timeline-logger');
+const changelogWriter = require('../../services/devloop/changelog-writer');
+
+describe('timelineLogger', () => {
+ let pid, root;
+
+ beforeEach(() => {
+ pathResolver._resetForTesting();
+ const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
+ pid = r.pid;
+ root = r.root_path;
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('append writes NDJSON line', () => {
+ timelineLogger.append(pid, {
+ source: 'system', kind: 'project_created', ref: pid,
+ summary: 'test project',
+ });
+ const events = timelineLogger.read(pid);
+ expect(events).toHaveLength(1);
+ expect(events[0].kind).toBe('project_created');
+ });
+
+ it('append rejects missing fields', () => {
+ expect(() => timelineLogger.append(pid, {})).toThrow();
+ expect(() => timelineLogger.append(pid, { source: 'x' })).toThrow();
+ });
+
+ it('summary truncated to 280 chars', () => {
+ const longSummary = 'x'.repeat(500);
+ timelineLogger.append(pid, {
+ source: 'system', kind: 'test', summary: longSummary,
+ });
+ const events = timelineLogger.read(pid);
+ expect(events[0].summary.length).toBeLessThanOrEqual(280);
+ expect(events[0].summary).toMatch(/…$/);
+ });
+
+ it('oversized payload reduced to ref marker', () => {
+ const big = { huge: 'x'.repeat(5000) };
+ timelineLogger.append(pid, {
+ source: 'system', kind: 'test', summary: 's', payload: big,
+ });
+ const events = timelineLogger.read(pid);
+ expect(events[0].payload.oversize).toBe(true);
+ });
+
+ it('tail returns last N', () => {
+ for (let i = 0; i < 15; i++) {
+ timelineLogger.append(pid, { source: 'system', kind: 'k', summary: `${i}` });
+ }
+ const last5 = timelineLogger.tail(pid, 5);
+ expect(last5).toHaveLength(5);
+ // 时间正序,最后一条是最新
+ expect(last5[4].summary).toBe('14');
+ });
+
+ it('filter by kind', () => {
+ timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'a' });
+ timelineLogger.append(pid, { source: 'arch', kind: 'sdd_generated', summary: 'b' });
+ timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'c' });
+ const r = timelineLogger.read(pid, { kind: 'prd_drafted' });
+ expect(r).toHaveLength(2);
+ });
+});
+
+describe('changelogWriter', () => {
+ let pid, root;
+
+ beforeEach(() => {
+ pathResolver._resetForTesting();
+ const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
+ pid = r.pid;
+ root = r.root_path;
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('append writes entry', () => {
+ changelogWriter.append(pid, {
+ kind: 'prd_rev', rev: 1, by: 'pm-agent',
+ trigger: 'init', summary: 'first PRD',
+ });
+ const all = changelogWriter.read(pid);
+ expect(all).toHaveLength(1);
+ expect(all[0].kind).toBe('prd_rev');
+ expect(all[0].rev).toBe(1);
+ });
+
+ it('rejects invalid kind', () => {
+ expect(() => changelogWriter.append(pid, {
+ kind: 'wrong-kind', rev: 1, summary: 's',
+ })).toThrow();
+ });
+
+ it('latest returns most recent of kind', () => {
+ changelogWriter.append(pid, { kind: 'prd_rev', rev: 1, summary: 's1' });
+ changelogWriter.append(pid, { kind: 'sdd_rev', rev: 1, summary: 's2' });
+ changelogWriter.append(pid, { kind: 'prd_rev', rev: 2, summary: 's3' });
+ const r = changelogWriter.latest(pid, 'prd_rev');
+ expect(r.rev).toBe(2);
+ });
+});
diff --git a/server/tests/devloop/unit-registry.test.js b/server/tests/devloop/unit-registry.test.js
new file mode 100644
index 0000000..c69e0e7
--- /dev/null
+++ b/server/tests/devloop/unit-registry.test.js
@@ -0,0 +1,157 @@
+/**
+ * unitRegistry 单测 — 跑 FSM 转换
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const docStore = require('../../services/devloop/doc-store');
+const unitRegistry = require('../../services/devloop/unit-registry');
+
+describe('unitRegistry', () => {
+ let pid;
+
+ beforeEach(() => {
+ pathResolver._resetForTesting();
+ const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' });
+ pid = r.pid;
+ // 准备 units.json
+ const units = {
+ project_id: pid, prd_rev: '1', sdd_rev: '1', l2_rounds: 0, l2_budget: 3,
+ units: [
+ {
+ id: 'auth-be', type: 'be', status: 'pending', depends_on: [],
+ retries: 0, retry_budget: 5, contract_hash: 'sha256:aaaaaaaaaaaaaaaa',
+ },
+ {
+ id: 'auth-fe', type: 'fe', status: 'pending', depends_on: ['auth-be'],
+ retries: 0, retry_budget: 5, contract_hash: 'sha256:bbbbbbbbbbbbbbbb',
+ },
+ ],
+ };
+ docStore.writeUnits(pid, units);
+ });
+
+ afterEach(() => {
+ const root = pathResolver.resolveProjectRoot(pid);
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ describe('list / pending / passed', () => {
+ it('lists all units', () => {
+ expect(unitRegistry.list(pid)).toHaveLength(2);
+ });
+ it('all start pending', () => {
+ expect(unitRegistry.pending(pid)).toHaveLength(2);
+ expect(unitRegistry.passed(pid)).toHaveLength(0);
+ });
+ });
+
+ describe('canTransition', () => {
+ it('pending → coding ok', () => {
+ expect(unitRegistry.canTransition('pending', 'coding')).toBe(true);
+ });
+ it('coding → testing ok', () => {
+ expect(unitRegistry.canTransition('coding', 'testing')).toBe(true);
+ });
+ it('pending → passed FORBIDDEN', () => {
+ expect(unitRegistry.canTransition('pending', 'passed')).toBe(false);
+ });
+ it('passed → pending ok (L2 reset)', () => {
+ expect(unitRegistry.canTransition('passed', 'pending')).toBe(true);
+ });
+ it('blocked → pending ok (人类干预)', () => {
+ expect(unitRegistry.canTransition('blocked', 'pending')).toBe(true);
+ });
+ });
+
+ describe('markCoding / markTesting / pass', () => {
+ it('full L1 happy path', () => {
+ unitRegistry.markCoding(pid, 'auth-be');
+ expect(unitRegistry.get(pid, 'auth-be').status).toBe('coding');
+
+ unitRegistry.markTesting(pid, 'auth-be');
+ expect(unitRegistry.get(pid, 'auth-be').status).toBe('testing');
+
+ unitRegistry.pass(pid, 'auth-be', 'sha256:newhash00000000');
+ const u = unitRegistry.get(pid, 'auth-be');
+ expect(u.status).toBe('passed');
+ expect(u.contract_hash).toBe('sha256:newhash00000000');
+ });
+
+ it('pending → testing throws', () => {
+ expect(() => unitRegistry.markTesting(pid, 'auth-be'))
+ .toThrow(unitRegistry.InvalidTransitionError);
+ });
+ });
+
+ describe('fail + retries', () => {
+ it('coding/testing → failed accumulates retries', () => {
+ unitRegistry.markCoding(pid, 'auth-be');
+ unitRegistry.markTesting(pid, 'auth-be');
+ unitRegistry.fail(pid, 'auth-be', 'test red');
+ expect(unitRegistry.get(pid, 'auth-be').retries).toBe(1);
+
+ unitRegistry.markCoding(pid, 'auth-be');
+ unitRegistry.markTesting(pid, 'auth-be');
+ unitRegistry.fail(pid, 'auth-be', 'test red 2');
+ expect(unitRegistry.get(pid, 'auth-be').retries).toBe(2);
+ });
+ });
+
+ describe('block + reset', () => {
+ it('failed → blocked then reset', () => {
+ unitRegistry.markCoding(pid, 'auth-be');
+ unitRegistry.markTesting(pid, 'auth-be');
+ unitRegistry.fail(pid, 'auth-be', 'red');
+ unitRegistry.block(pid, 'auth-be', 'budget exceeded');
+ expect(unitRegistry.get(pid, 'auth-be').status).toBe('blocked');
+
+ const r = unitRegistry.reset(pid, ['auth-be']);
+ expect(r.reset).toHaveLength(1);
+ expect(unitRegistry.get(pid, 'auth-be').status).toBe('pending');
+ expect(unitRegistry.get(pid, 'auth-be').retries).toBe(0);
+ });
+
+ it('reset skips coding/testing units with reason', () => {
+ unitRegistry.markCoding(pid, 'auth-be');
+ const r = unitRegistry.reset(pid, ['auth-be', 'ghost']);
+ expect(r.reset).toHaveLength(0);
+ expect(r.skipped.find(s => s.id === 'auth-be').reason).toMatch(/coding/);
+ expect(r.skipped.find(s => s.id === 'ghost').reason).toBe('not_found');
+ });
+ });
+
+ describe('l2GateOpen', () => {
+ it('false when any not passed', () => {
+ expect(unitRegistry.l2GateOpen(pid)).toBe(false);
+ });
+ it('true when all passed', () => {
+ for (const id of ['auth-be', 'auth-fe']) {
+ unitRegistry.markCoding(pid, id);
+ unitRegistry.markTesting(pid, id);
+ unitRegistry.pass(pid, id, 'sha256:0000000000000000');
+ }
+ expect(unitRegistry.l2GateOpen(pid)).toBe(true);
+ });
+ });
+
+ describe('diffUnits', () => {
+ it('detects added/changed/removed', () => {
+ const oldList = [
+ { id: 'a-be', contract_hash: 'sha256:1' },
+ { id: 'b-be', contract_hash: 'sha256:2' },
+ ];
+ const newList = [
+ { id: 'a-be', contract_hash: 'sha256:1' }, // same
+ { id: 'b-be', contract_hash: 'sha256:CHANGED' }, // changed
+ { id: 'c-be', contract_hash: 'sha256:3' }, // added
+ ];
+ const r = unitRegistry.diffUnits(oldList, newList);
+ expect(r.added).toEqual(['c-be']);
+ expect(r.changed).toEqual(['b-be']);
+ expect(r.removed).toEqual([]);
+ });
+ });
+});