feat: видимость вердиктов роутер и gate3 в баннер и снимок-стор
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# Полная видимость вердиктов гейтов переговоров — «всё в лоб»
|
||||
|
||||
**Дата:** 2026-06-17
|
||||
**Эпик:** роутер-наставник, наблюдаемость решений. **Статус:** дизайн под реализацию (TDD).
|
||||
**Предшественник:** SP1 видимость (`verdict-surface-store.mjs` + `enforce-verdict-surface.mjs` +
|
||||
`verdict-outcome-line.mjs` + `enforce-verdict-ack.mjs`).
|
||||
|
||||
## Цель
|
||||
|
||||
Разработчик должен видеть **все** решения участников цикла переговоров (роутер, наставник по
|
||||
спеке и плану, судья по спеке/плану/цели) и факт печати — **в момент их готовности**, единым
|
||||
достоверным источником, не выводя их по косвенным артефактам. Сейчас в видимость попадают только
|
||||
наставник и судья (по спеке/плану) и только на старте следующего хода (баннер), а решение роутера
|
||||
и судьи цели (gate3) теряются. Латентность одного решения — до 5 минут, участники считают
|
||||
последовательно, поэтому пассивное «подождать и прочитать» не годится: нужен снимок-источник
|
||||
правды с по-этапным статусом и наблюдатель, доставляющий каждое решение по мере готовности.
|
||||
|
||||
## Снимок-стор решений {#snap}
|
||||
|
||||
Единый пер-сессионный транзиентный файл `verdict-snapshot-<сессия>.json` (рядом с очередью SP1).
|
||||
Чистые функции записи/чтения; fail-quiet (любая ошибка → безопасный no-op/пусто).
|
||||
|
||||
Запись на участника+артефакт: `{ stage, hash, status, reason, ts }`, где:
|
||||
|
||||
- **stage** — `router` | `mentor:spec` | `mentor:plan` | `judge:spec` | `judge:plan` | `judge:gate3` | `seal`;
|
||||
- **hash** — идентификатор артефакта (план/спека), к которому относится решение;
|
||||
- **status** — `pending` (старт) → одно из `GO` | `NO-GO` | `degraded` | `skip` | `recommend` (для роутера) | `sealed`;
|
||||
- **reason** — дословное обоснование/рекомендация;
|
||||
- **ts** — момент обновления.
|
||||
|
||||
Старт стадии пишет `status:'pending'`; завершение перезаписывает её на конечный статус. Чтение
|
||||
по `hash` возвращает текущую карту стадий. Снимок персистентен внутри сессии (в отличие от очереди,
|
||||
дренящейся один раз) — читается достоверно в любой момент.
|
||||
|
||||
## По-этапная доставка наблюдателем {#watch}
|
||||
|
||||
Read-only наблюдатель `verdict-wait` следит за снимком и доставляет решения **по этапам** с ранним
|
||||
возвратом: ждёт конкретную стадию по `hash`; как только её статус перешёл из `pending` в конечный —
|
||||
немедленно возвращает (лёг за 30с → вернул за 30с; до предела стадии — ждёт молча). Предел —
|
||||
**на стадию** (до 5 минут), не общий на весь цикл: реальное ожидание = сумма фактических, а каждое
|
||||
решение видно в момент готовности. Наблюдатель не мутирует состояние (только чтение снимка).
|
||||
|
||||
## Печать и все исходы в лоб {#face}
|
||||
|
||||
Доставке подлежит **каждое** приземление, не только печать: решение роутера, наставника (спека/план),
|
||||
судьи (спека/план/цель) и событие печати (`seal`). Печать — асинхронна (ставится спустя минуты после
|
||||
записи артефакта), поэтому доставляется тем же наблюдателем как стадия `seal`, громким событием
|
||||
(«печать встала: <артефакт> <gate> <итог>»). Ни одно решение не требует от разработчика «пойти
|
||||
посмотреть» — оно приходит само.
|
||||
|
||||
## Полнота канала подтверждения {#banner}
|
||||
|
||||
Существующий баннер (на старте следующего хода, `enforce-verdict-surface`) дополняется недостающими
|
||||
входами — решением роутера и судьи цели (gate3) — чтобы оставаться полным как страховочный канал
|
||||
подтверждения (ack). Источник правды — снимок {#snap}; баннер — удобная сводка, может отставать от
|
||||
многоминутного решения, поэтому не единственный.
|
||||
|
||||
## Проводка недостающих производителей {#wire}
|
||||
|
||||
- **Роутер.** Результат `classify` сейчас оседает внутри `onPlanWrite` (используется для контекста
|
||||
навыков) и наружу не возвращается. Вернуть его рядом с вердиктом наставника, чтобы вызыватель мог
|
||||
записать стадию `router` в снимок и баннер. Исход роутера — `recommend` (рекомендует цепочку) /
|
||||
`skip` (скил не нужен, `direct`) / `degraded` (недоступен). Роутер не блокирует — это информативная
|
||||
стадия.
|
||||
- **Судья цели (gate3).** Завершение цели судит Stop-обработчик; его решение в снимок/баннер сейчас
|
||||
не идёт. Добавить запись стадии `judge:gate3` (GO/NO-GO/degraded) в снимок и баннер.
|
||||
|
||||
## Крайние случаи {#edge}
|
||||
|
||||
- **Решение пришло после дренажа очереди** — снимок персистентен, поэтому статус всё равно читается;
|
||||
баннер догонит на следующем дренаже. Не теряется.
|
||||
- **degraded** (участник не дозвонился) маркируется явно отдельным статусом — не выдаётся за исход.
|
||||
- **Предел стадии исчерпан** (до 5 минут без приземления) — наблюдатель возвращает явный
|
||||
`timeout`-итог по стадии (не молчит и не выдаёт пустое за результат).
|
||||
- Всё fail-quiet: сбой записи/чтения снимка никогда не роняет ход и не подделывает решение.
|
||||
|
||||
## Конвенция {#conv}
|
||||
|
||||
Чистые экспортируемые функции снимка и классификации исхода тестируемы в изоляции без модели/IO.
|
||||
Наблюдатель — строго read-only (только чтение снимка, без записи/мутаций). Без новых внешних
|
||||
зависимостей. Изменения существующих производителей — аддитивные (добавляют запись стадии, не
|
||||
меняют их вердиктную логику).
|
||||
|
||||
## ОТЛОЖЕНО — НЕ ПОТЕРЯТЬ {#deferred}
|
||||
|
||||
Судья пользовательской карточки (`gate3card`, Фаза 2b приёмки владельца — ещё не построен) при
|
||||
появлении **обязан** писать своё решение в снимок {#snap} и баннер {#banner} тем же каналом, что
|
||||
роутер/наставник/судья. Зафиксировано здесь как непропускаемый пункт: добавить стадию `judge:gate3card`
|
||||
в момент постройки судьи карточек, иначе видимость снова станет неполной.
|
||||
|
||||
## Критерий приёмки {#accept}
|
||||
|
||||
TDD-тесты:
|
||||
|
||||
- снимок: старт пишет `pending`; завершение перезаписывает на конечный статус; чтение по `hash`
|
||||
возвращает карту стадий; неизвестный hash → пусто; сбой → fail-quiet no-op.
|
||||
- классификация исхода роутера: `recommend` / `skip` / `degraded` по форме результата `classify`.
|
||||
- наблюдатель: ранний возврат при переходе стадии `pending→конечный`; предел на стадию (не общий);
|
||||
read-only (снимок не мутируется).
|
||||
- проводка: результат роутера возвращается из `onPlanWrite`; стадия `router` пишется в снимок;
|
||||
стадия `judge:gate3` пишется из Stop-обработчика.
|
||||
- баннер: содержит роутер и gate3, когда они есть.
|
||||
- полная регрессия tools зелёная: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
(база зелёная + новые тесты; 0 регрессий в SP1-модулях).
|
||||
|
||||
```verified-context-json
|
||||
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/verdict-surface-store.mjs","anchor":"export function pushVerdict("}]
|
||||
```
|
||||
@@ -69,6 +69,15 @@ export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = nul
|
||||
return { block: true, clear: false, state: d.state, card: !!d.card, reason: d.reason };
|
||||
}
|
||||
|
||||
/** Видимость gate3: вердикт судьи цели → запись стадии снимка/баннера. Чистая (без import). */
|
||||
export function gate3SurfaceRecord({ verdict, hash } = {}) {
|
||||
let status = 'skip';
|
||||
if (verdict && verdict.wired === false) status = 'degraded';
|
||||
else if (verdict && verdict.decision === 'GO') status = 'GO';
|
||||
else if (verdict && verdict.decision === 'NO-GO') status = 'NO-GO';
|
||||
return { stage: 'judge:gate3', hash: hash || null, status, reason: (verdict && verdict.reason) || '' };
|
||||
}
|
||||
|
||||
/** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */
|
||||
export async function runGate3Stop(event, deps) {
|
||||
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now } = deps;
|
||||
@@ -93,6 +102,13 @@ export async function runGate3Stop(event, deps) {
|
||||
const isContentGo = !!verdict && verdict.wired === true && verdict.decision === 'GO';
|
||||
noGoCount = isContentNoGo ? noGoCount + 1 : (isContentGo ? 0 : noGoCount);
|
||||
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount } });
|
||||
// Видимость «всё в лоб»: свежий gate3-вердикт → снимок + баннер (fail-quiet, не влияет на зубы).
|
||||
try {
|
||||
const { writeStage, pushVerdict } = await import('./verdict-surface-store.mjs');
|
||||
const rec = gate3SurfaceRecord({ verdict, hash: marker.planId });
|
||||
writeStage(sess, { ...rec, ts: Date.now() }, runtimeDir);
|
||||
pushVerdict(sess, { outcome: rec.status, gate: 'judge:gate3', round: null, version: null, reason: rec.reason }, runtimeDir);
|
||||
} catch { /* fail-quiet */ }
|
||||
}
|
||||
|
||||
const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now });
|
||||
|
||||
@@ -92,6 +92,27 @@ describe('buildGate3ProductFromMarker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
import { gate3SurfaceRecord } from './enforce-gate3-loop.mjs';
|
||||
|
||||
describe('gate3SurfaceRecord (видимость gate3)', () => {
|
||||
it('GO verdict → stage judge:gate3, status GO', () => {
|
||||
expect(gate3SurfaceRecord({ verdict: { wired: true, decision: 'GO' }, hash: 'h' }))
|
||||
.toEqual({ stage: 'judge:gate3', hash: 'h', status: 'GO', reason: '' });
|
||||
});
|
||||
it('NO-GO → status NO-GO, reason дословно', () => {
|
||||
const r = gate3SurfaceRecord({ verdict: { wired: true, decision: 'NO-GO', reason: 'не достигнуто' }, hash: 'h' });
|
||||
expect(r.status).toBe('NO-GO');
|
||||
expect(r.reason).toBe('не достигнуто');
|
||||
});
|
||||
it('degraded (wired:false) → degraded', () => {
|
||||
expect(gate3SurfaceRecord({ verdict: { wired: false }, hash: 'h' }).status).toBe('degraded');
|
||||
});
|
||||
it('нет verdict → skip, hash null', () => {
|
||||
expect(gate3SurfaceRecord({}).status).toBe('skip');
|
||||
expect(gate3SurfaceRecord({}).hash).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loop marker delivery', () => {
|
||||
const KEY = 'k-loop-deliv';
|
||||
it('delivery в подписанной метке верифицируется и ломается при подмене', () => {
|
||||
|
||||
@@ -199,7 +199,7 @@ export async function runMentorOnPlanWrite(event, {
|
||||
const planHash = planId(steps);
|
||||
try { persistVerdictImpl({ ok: r.ok, wired: r.wired, reason: r.reason ?? null, planHash, verdict: r.verdict }); } catch { /* best-effort */ }
|
||||
if (r.journalOk && r.journal) { try { persistJournalImpl(r.journal); } catch { /* best-effort (SE10) */ } }
|
||||
return { ran: true, ok: r.ok, wired: r.wired, reason: r.reason, taskId: r.taskId, planHash, verdict: r.verdict };
|
||||
return { ran: true, ok: r.ok, wired: r.wired, reason: r.reason, taskId: r.taskId, planHash, verdict: r.verdict, routerClassification: r.routerClassification ?? null };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -233,13 +233,22 @@ async function main() {
|
||||
if (res && res.ran) {
|
||||
// SP1: громкая видимость вердикта наставника (best-effort, fail-quiet).
|
||||
try {
|
||||
pushVerdict(sess, {
|
||||
outcome: classifyJudgeOutcome({ wired: res.wired, decision: res.verdict && res.verdict.decision }),
|
||||
gate: 'mentor',
|
||||
round: null,
|
||||
version: null,
|
||||
reason: (res.verdict && res.verdict.recommendation) || res.reason || '',
|
||||
});
|
||||
const mentorOutcome = classifyJudgeOutcome({ wired: res.wired, decision: res.verdict && res.verdict.decision });
|
||||
const mentorReason = (res.verdict && res.verdict.recommendation) || res.reason || '';
|
||||
pushVerdict(sess, { outcome: mentorOutcome, gate: 'mentor', round: null, version: null, reason: mentorReason });
|
||||
// Видимость «всё в лоб»: снимок-стадии наставника + роутера (баннер + персистентный снимок).
|
||||
const fpV = String((event.tool_input && event.tool_input.file_path) || '');
|
||||
const stageM = SPEC_PATH_RE.test(fpV) ? 'mentor:spec' : 'mentor:plan';
|
||||
const { writeStage } = await import('./verdict-surface-store.mjs');
|
||||
writeStage(sess, { stage: stageM, hash: res.planHash, status: mentorOutcome, reason: mentorReason, ts: Date.now() });
|
||||
if (res.routerClassification) {
|
||||
const { classifyRouterOutcome } = await import('./verdict-outcome-line.mjs');
|
||||
const routerOutcome = classifyRouterOutcome(res.routerClassification);
|
||||
const rc = res.routerClassification.recommended_chain;
|
||||
const routerReason = Array.isArray(rc) && rc.length ? `рекомендует: ${rc.join(', ')}` : 'скил не требуется';
|
||||
pushVerdict(sess, { outcome: routerOutcome, gate: 'router', round: null, version: null, reason: routerReason });
|
||||
writeStage(sess, { stage: 'router', hash: res.planHash, status: routerOutcome, reason: routerReason, ts: Date.now() });
|
||||
}
|
||||
} catch { /* fail-quiet */ }
|
||||
// Р7/§3.4: счётчик L1 растёт на NO-GO = содержательный decision='NO-GO' ИЛИ сломанный
|
||||
// вердикт (ok!==true). degraded (wired:false) не считается (escalation L1 не растёт).
|
||||
|
||||
@@ -139,6 +139,12 @@ describe('runMentorOnPlanWrite (обёртка-производитель W7)',
|
||||
expect(r.ran).toBe(true);
|
||||
expect(capturedUser).toContain('память дорожки spec');
|
||||
});
|
||||
// Видимость: результат роутера протягивается наружу из runMentorOnPlanWrite (для снимка/баннера).
|
||||
it('видимость: runMentorOnPlanWrite (план) возвращает routerClassification', async () => {
|
||||
const d = deps({ classifyImpl: async () => ({ recommended_chain: ['systematic-debugging'] }) });
|
||||
const r = await runMentorOnPlanWrite(planEvent, d);
|
||||
expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideMentorObjection (Фаза 1 — канал замечаний наставника контроллеру)', () => {
|
||||
|
||||
@@ -60,11 +60,13 @@ export async function onPlanWrite({
|
||||
// Мерж роутер↔наставник: зовём classify() как функцию (мозг роутера цел). Сбой/недоступен →
|
||||
// recommendedChain=null → наставник судит план БЕЗ скил-сверки (fail-safe §5, не ложный NO-GO).
|
||||
let recommendedChain = null;
|
||||
let routerClassification = null; // видимость: сырой результат classify наружу для снимка/баннера
|
||||
if (typeof classifyImpl === 'function') {
|
||||
try {
|
||||
const c = await classifyImpl(planGoal, registry);
|
||||
routerClassification = c ?? null;
|
||||
recommendedChain = recommendedChainOf(c);
|
||||
} catch { recommendedChain = null; }
|
||||
} catch { recommendedChain = null; routerClassification = { unavailable: true }; }
|
||||
}
|
||||
const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain, registry });
|
||||
// Производитель вердикта (C T5b): сбой → ok:false/wired:false (SE-R6-6, не суд).
|
||||
@@ -86,7 +88,7 @@ export async function onPlanWrite({
|
||||
}, { key: journalKey, nowMs });
|
||||
journalOk = true;
|
||||
} catch { journal = null; journalOk = false; }
|
||||
return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk };
|
||||
return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk, routerClassification };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,6 +101,30 @@ describe('onPlanWrite — скил-сверка через classify (мерж)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('onPlanWrite возвращает результат роутера наружу (видимость)', () => {
|
||||
const GO = { plan_points_addressed: ['ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' };
|
||||
it('routerClassification = результат classify', async () => {
|
||||
const r = await onPlanWrite({
|
||||
planSteps: STEPS,
|
||||
classifyImpl: async () => ({ recommended_chain: ['systematic-debugging'] }),
|
||||
registry: {}, llmCall: async () => GO, journalKey: 'k', nowMs: 1, planGoal: 'g',
|
||||
});
|
||||
expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] });
|
||||
});
|
||||
it('classifyImpl бросил → routerClassification = { unavailable: true }', async () => {
|
||||
const r = await onPlanWrite({
|
||||
planSteps: STEPS,
|
||||
classifyImpl: async () => { throw new Error('нет'); },
|
||||
registry: {}, llmCall: async () => GO, journalKey: 'k', nowMs: 1, planGoal: 'g',
|
||||
});
|
||||
expect(r.routerClassification).toEqual({ unavailable: true });
|
||||
});
|
||||
it('без classifyImpl → routerClassification = null (роутер не звался)', async () => {
|
||||
const r = await onPlanWrite({ planSteps: STEPS, llmCall: async () => GO, journalKey: 'k', nowMs: 1 });
|
||||
expect(r.routerClassification).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('оркестратор протягивает roundMemory до построителя (SP2c-2)', () => {
|
||||
it('onPlanWrite → roundMemory доходит до промпта вердикта', async () => {
|
||||
let capturedUser = null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/** verdict-outcome-line — чистые функции отображения вердикта (SP1, спек 2026-06-16-verdict-visibility). */
|
||||
|
||||
const EMOJI = { 'GO': '✅', 'NO-GO': '🚫', 'degraded': '⚠', 'skip': '⤳' };
|
||||
const EMOJI = { 'GO': '✅', 'NO-GO': '🚫', 'degraded': '⚠', 'skip': '⤳', 'recommend': '🧭' };
|
||||
const BAR = '━'.repeat(36);
|
||||
|
||||
/** Сырой вердикт судьи/наставника → исход. wired:false = degraded; иначе по decision. */
|
||||
@@ -32,5 +32,18 @@ export function buildVerdictBanner({ outcome, gate, round, version, reason } = {
|
||||
|
||||
/** true, если в первых строках текста есть подтверждение `вердикт: <outcome>`. */
|
||||
export function parseVerdictAck(text) {
|
||||
return /^\s*вердикт:\s*(GO|NO-GO|NOGO|degraded|skip)\b/im.test(String(text || ''));
|
||||
return /^\s*вердикт:\s*(GO|NO-GO|NOGO|degraded|skip|recommend)\b/im.test(String(text || ''));
|
||||
}
|
||||
|
||||
/** Исход роутера (совещательный, не блокирует): recommend (рекомендует цепочку) / skip (скил не
|
||||
* нужен, direct) / degraded (недоступен/сбой). Вход — результат classify (recommended_chain или
|
||||
* recommended_node/node). */
|
||||
export function classifyRouterOutcome(c) {
|
||||
if (!c || typeof c !== 'object') return 'degraded';
|
||||
if (c.unavailable === true || c.wired === false) return 'degraded';
|
||||
const chain = Array.isArray(c.recommended_chain) ? c.recommended_chain : [];
|
||||
if (chain.length) return 'recommend';
|
||||
const node = c.recommended_node ?? c.node ?? null;
|
||||
if (node && node !== 'direct') return 'recommend';
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
@@ -31,3 +31,34 @@ describe('parseVerdictAck', () => {
|
||||
expect(parseVerdictAck('обычный ответ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { classifyRouterOutcome } from './verdict-outcome-line.mjs';
|
||||
|
||||
describe('classifyRouterOutcome (видимость роутера)', () => {
|
||||
it('recommended_chain непустой → recommend', () => {
|
||||
expect(classifyRouterOutcome({ recommended_chain: ['skill-x'] })).toBe('recommend');
|
||||
});
|
||||
it('recommended_node не direct → recommend', () => {
|
||||
expect(classifyRouterOutcome({ recommended_node: 'skill-y' })).toBe('recommend');
|
||||
});
|
||||
it('direct / пустой → skip (скил не нужен)', () => {
|
||||
expect(classifyRouterOutcome({ recommended_node: 'direct' })).toBe('skip');
|
||||
expect(classifyRouterOutcome({ recommended_chain: [] })).toBe('skip');
|
||||
});
|
||||
it('null / недоступен → degraded', () => {
|
||||
expect(classifyRouterOutcome(null)).toBe('degraded');
|
||||
expect(classifyRouterOutcome({ unavailable: true })).toBe('degraded');
|
||||
expect(classifyRouterOutcome({ wired: false })).toBe('degraded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildVerdictBanner + parseVerdictAck: recommend', () => {
|
||||
it('баннер recommend несёт ярлык и гейт', () => {
|
||||
const b = buildVerdictBanner({ outcome: 'recommend', gate: 'router', reason: 'рекомендую skill-x' });
|
||||
expect(b).toContain('recommend');
|
||||
expect(b).toContain('[router]');
|
||||
});
|
||||
it('parseVerdictAck ловит вердикт: recommend', () => {
|
||||
expect(parseVerdictAck('вердикт: recommend')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,3 +51,34 @@ export function clearPendingAck(sid, baseDir) {
|
||||
try { const p = ackPath(sid, baseDir); if (existsSync(p)) writeFileSync(p, 'null'); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// Снимок стадий (видимость «всё в лоб»): персистентная карта решений по артефакту, читаемая в любой
|
||||
// момент (в отличие от очереди, дренящейся один раз). Файл = { [hash]: { [stage]: {status,reason,ts} } }.
|
||||
function snapshotPath(sid, baseDir) { return join(baseOf(baseDir), `verdict-snapshot-${sid || 'unknown'}.json`); }
|
||||
|
||||
/** Upsert стадии решения по hash артефакта. status: pending → GO|NO-GO|degraded|skip|recommend|sealed. */
|
||||
export function writeStage(sid, { stage, hash, status, reason = '', ts = 0 } = {}, baseDir) {
|
||||
try {
|
||||
if (!stage || !hash) return false;
|
||||
ensureDir(baseDir);
|
||||
const p = snapshotPath(sid, baseDir);
|
||||
let obj = existsSync(p) ? readJson(p) : {};
|
||||
if (!obj || typeof obj !== 'object') obj = {};
|
||||
if (!obj[hash] || typeof obj[hash] !== 'object') obj[hash] = {};
|
||||
obj[hash][stage] = { status, reason, ts };
|
||||
writeFileSync(p, JSON.stringify(obj));
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Карта стадий по hash: { [stage]: {status,reason,ts} } или {} (неизвестный hash / сбой). */
|
||||
export function readSnapshot(sid, hash, baseDir) {
|
||||
try {
|
||||
const p = snapshotPath(sid, baseDir);
|
||||
if (!existsSync(p)) return {};
|
||||
const obj = readJson(p);
|
||||
if (!obj || typeof obj !== 'object') return {};
|
||||
const v = obj[hash];
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
@@ -30,3 +30,37 @@ describe('verdict-surface-store', () => {
|
||||
expect(drainVerdicts('s1', '\0bad')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
import { writeStage, readSnapshot } from './verdict-surface-store.mjs';
|
||||
|
||||
describe('verdict-surface-store снимок стадий (видимость «всё в лоб»)', () => {
|
||||
it('writeStage pending → читается через readSnapshot по hash', () => {
|
||||
const d = dir();
|
||||
writeStage('s1', { stage: 'mentor:plan', hash: 'h1', status: 'pending', ts: 1 }, d);
|
||||
expect(readSnapshot('s1', 'h1', d)['mentor:plan'].status).toBe('pending');
|
||||
});
|
||||
it('завершение перезаписывает pending на исход (та же стадия+hash)', () => {
|
||||
const d = dir();
|
||||
writeStage('s1', { stage: 'mentor:plan', hash: 'h1', status: 'pending', ts: 1 }, d);
|
||||
writeStage('s1', { stage: 'mentor:plan', hash: 'h1', status: 'NO-GO', reason: 'переделай', ts: 2 }, d);
|
||||
const snap = readSnapshot('s1', 'h1', d);
|
||||
expect(snap['mentor:plan'].status).toBe('NO-GO');
|
||||
expect(snap['mentor:plan'].reason).toBe('переделай');
|
||||
});
|
||||
it('разные стадии одного hash сосуществуют', () => {
|
||||
const d = dir();
|
||||
writeStage('s1', { stage: 'router', hash: 'h1', status: 'recommend', ts: 1 }, d);
|
||||
writeStage('s1', { stage: 'judge:plan', hash: 'h1', status: 'GO', ts: 2 }, d);
|
||||
const snap = readSnapshot('s1', 'h1', d);
|
||||
expect(snap.router.status).toBe('recommend');
|
||||
expect(snap['judge:plan'].status).toBe('GO');
|
||||
});
|
||||
it('неизвестный hash → пустой объект', () => {
|
||||
const d = dir();
|
||||
expect(readSnapshot('s1', 'nope', d)).toEqual({});
|
||||
});
|
||||
it('fail-quiet: битый baseDir не кидает (снимок)', () => {
|
||||
expect(() => writeStage('s1', { stage: 'router', hash: 'h', status: 'GO' }, '\0bad')).not.toThrow();
|
||||
expect(readSnapshot('s1', 'h', '\0bad')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user