Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
86 lines
22 KiB
JavaScript
86 lines
22 KiB
JavaScript
/** 흐름 4·5·6·7·8·9·23·27: 초안 사이트 → Webmaker SSO·저장본 → 관리형 배포(실제 HAL·Agent) → 도메인 연결 → 외부 확인 → 통계 → 실패 작업 재시도·되돌리기 → 마지막 사이트 수 동시 요청. */
|
||
import { steps } from '../report.mjs';
|
||
import { waitFor, sleep } from '../util.mjs';
|
||
import { signupVerified } from './10-identity.mjs';
|
||
import { orderAndApprove } from './20-commerce.mjs';
|
||
export const BUDGET_MS = 150000;
|
||
|
||
/** 포털 작업이 끝날 때까지(최대 ms) 사이트 작업 조회 */
|
||
export async function waitSiteJob(client, siteId, jobId, ms = 60000) { return waitFor(async () => { const r = await client.get(`/api/sites/${siteId}/jobs/${jobId}`); const j = r.json?.job || r.json; return j && ['done', 'failed', 'cancelled', 'reverted', 'needs_user', 'awaiting_external'].includes(j.status) ? j : null; }, { timeoutMs: ms, intervalMs: 1000, label: 'site job' }); }
|
||
/** 도메인 연결 작업 취소(결함: 외부 대기 중 5초마다 HAL domain.probe 재제출 — 뒤 흐름의 HAL 큐 보호용) */
|
||
export async function cancelDomainJob(client, domainId) { const x = await client.get(`/api/domains/${domainId}`); const jid = x.json?.job?.id; if (!jid) return null; const r = await client.post(`/api/domains/${domainId}/jobs/${jid}/cancel`); return r.status; }
|
||
export async function createSiteWithVersion(env, client, name, { deployMode = 'managed' } = {}) {
|
||
const r = await client.post('/api/sites', { name, purpose: 'e2e', projectMode: 'new', buildMode: 'manual', deployMode }); if (r.status !== 200) throw new Error(`사이트 생성 실패 ${r.status} ${r.text.slice(0, 200)}`);
|
||
const site = r.json.site; await env.wm.addVersion(site.webmaker_project_id); return site;
|
||
}
|
||
export async function run({ env, report, state, log }) {
|
||
const alice = state.alice; if (!alice) { for (const n of [4, 5, 6, 7, 8, 9, 23, 27]) report.flow(n, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; }
|
||
let site = null;
|
||
// ---- 4 초안 사이트
|
||
{ const s = steps(); try {
|
||
site = await s.run('사이트 생성(새 Webmaker 프로젝트·배포 방식 관리형) → 초안·사이트 수 미소비·다음 단계 안내', async () => { const r = await alice.post('/api/sites', { name: 'E2E 사이트', purpose: '검증', projectMode: 'new', buildMode: 'manual', deployMode: 'managed', lang: 'ko', timezone: 'Asia/Seoul' }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); if (r.json.site.lifecycle !== 'draft' || r.json.site.site_count_state !== 'none') throw new Error(JSON.stringify(r.json.site).slice(0, 200)); state._created = r.json.site; return { id: r.json.site.id, lifecycle: r.json.site.lifecycle, projectId: r.json.site.webmaker_project_id, nextSteps: r.json.nextSteps?.map((x) => x.id) }; }) && state._created;
|
||
await s.run('사이트 목록 필터(초안)·사용량 요약에 예약 0', async () => { const l = await alice.get('/api/sites?filter=draft'); const u = await alice.get('/api/usage'); const sites = u.json.resources?.find((x) => x.resource === 'sites'); return { drafts: l.json.sites.length, sitesReserved: sites?.reserved, sitesAllocated: sites?.allocated }; });
|
||
state.site = site; report.flow(4, 'verified_local', { steps: s.list, note: '초안 생성·Webmaker 프로젝트 연결·사이트 수 미소비·목록 필터' });
|
||
} catch (e) { report.flow(4, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '초안 사이트 생성 실패', file: 'portal/sites/api.mjs', route: 'POST /api/sites', expected: 'draft site', actual: e.message, repro: '30-sites.mjs', flows: [4] }); } }
|
||
if (!site) { for (const n of [5, 6, 7, 8, 9, 23]) report.flow(n, 'partial', { note: '사이트 없음', blockedBy: 4 }); return; }
|
||
// ---- 5 Webmaker 제작(SSO 진입 + 저장본)
|
||
{ const s = steps(); try {
|
||
const sso = await s.run('제작 화면 열기: 포털 SSO 티켓 → Webmaker /api/auth/sso 302(1회용)', async () => { const r = await alice.get(`/sso/webmaker?site=${site.id}`); if (r.status !== 302) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); const loc = r.headers.location; const w = await fetch(loc, { redirect: 'manual' }); const again = await fetch(loc, { redirect: 'manual' }); if (w.status !== 302) throw new Error(`webmaker ${w.status}`); return { portalRedirect: loc.replace(/ticket=.*/, 'ticket=…'), webmaker: w.status, reuse: again.status }; });
|
||
const ver = await s.run('Webmaker 에서 페이지 저장(저장본 2개) → 포털 저장본 목록·배포 전 확인 통과', async () => { await env.wm.addVersion(site.webmaker_project_id); const v = await alice.get(`/api/sites/${site.id}/versions`); const pc = await alice.get(`/api/sites/${site.id}/precheck`); if (!pc.json.precheck?.ok) throw new Error('precheck: ' + JSON.stringify(pc.json.precheck?.items?.filter((i) => i.state === 'block')).slice(0, 300)); return { versions: (v.json.versions || v.json.versionList || []).length, precheckOk: pc.json.precheck.ok }; });
|
||
report.flow(5, 'verified_local', { steps: s.list, note: 'SSO 티켓(1회용) 진입·저장본 목록·배포 전 확인(가짜 Webmaker — 실제 편집 UI 는 포함하지 않음)', evidence: { sso, ver } });
|
||
} catch (e) { report.flow(5, 'partial', { steps: s.list, note: e.message }); } }
|
||
// ---- 6 관리형 배포 (실제 HAL → 실제 Agent)
|
||
let deployJob = null; { const s = steps(); try {
|
||
const d = await s.run('배포 요청 → 10단계 작업·사이트 수 예약·같은 요청 재제출은 하나', async () => { const a = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post(`/api/sites/${site.id}/deploy`, { versionRef: 'current' }); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { jobId: a.json.job.id, steps: a.json.job.steps.length, again: b.json.created, siteCount: st.site_count_state, server: st.server_id }; });
|
||
deployJob = await s.run('HAL·Agent 를 거쳐 작업 완료 대기(최대 40초)', async () => { const j = await waitSiteJob(alice, site.id, d.jobId, 40000); const halAll = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=20')).json?.jobs || []; const halJobs = { count: halAll.length, first: halAll[0] ? [halAll[0].status, (halAll[0].error || '').slice(0, 140)] : null }; const cur = (await alice.get(`/api/sites/${site.id}/jobs/${d.jobId}`)).json; const cj = cur.job || cur; if (!j || j.timedOut) throw new Error(`시간 초과 — 포털 작업 ${cj.status}(attempt ${cj.attempt}, ${cj.waitReason || cj.wait_reason || ''}) · HAL site.deploy 작업 ${halJobs.count}개(최근: ${JSON.stringify(halJobs.first)})`); if (j.status !== 'done') throw new Error(`${j.status}: ${j.error} (${j.errorCode}) 단계=${JSON.stringify((j.steps || []).map((x) => x.id + ':' + x.state))}`); return { status: j.status, verify: j.result?.verify, siteUrl: j.result?.siteUrl, steps: j.steps.map((x) => x.id + ':' + x.state) }; });
|
||
const after = await s.run('배포 뒤 사이트 상태·버전·건강', async () => { const st = (await alice.get(`/api/sites/${site.id}`)).json.site; const ag = env.servers.web; return { lifecycle: st.lifecycle, version: st.current_version, verify: st.deploy_verify, health: st.health, agentHasSite: (await import('node:fs')).existsSync(`${ag.roots.sites}/${site.id}/current`) || (await import('node:fs')).existsSync(`${env.servers.web2.roots.sites}/${site.id}/current`) }; });
|
||
report.flow(6, 'verified_local', { steps: s.list, note: '배포 요청→예약→HAL 작업→Agent(stub) 배포→도메인 설정 필요 상태(대표 도메인 없음)', evidence: { d, deployJob, after } });
|
||
} catch (e) {
|
||
// 포털 경로가 막힌 경우: HAL·Agent 쪽 배포 경로(로컬 저장본) 만 따로 확인해 결함 위치를 좁힌다
|
||
await s.run('[HAL 직접] 같은 Agent 에 로컬 저장본으로 site.deploy → 성공하면 결함은 포털→Agent 번들 전달 구간', async () => { const ag = env.servers.web; const { mkdirSync, writeFileSync, existsSync } = await import('node:fs'); const bdir = `${ag.dir}/bundle-direct`; mkdirSync(bdir, { recursive: true }); writeFileSync(`${bdir}/index.html`, '<h1>direct marker-direct</h1>'); const r = await env.halSigned('POST', '/portal/v1/jobs', { kind: 'site.deploy', target: ag.serverId, payload: { siteId: 'direct_e2e', versionRef: 'v1', bundlePath: bdir, domains: [], expectMarker: 'marker-direct' }, idempotencyKey: 'e2e-direct-deploy' }); const j = await waitFor(async () => { const x = await env.halSigned('GET', `/portal/v1/jobs/${r.json.halJobId}`); return x.json && !['queued', 'running'].includes(x.json.status) ? x.json : null; }, { timeoutMs: 15000, intervalMs: 1000 }); return { status: j?.status, error: j?.error, layout: existsSync(`${ag.roots.sites}/direct_e2e/current`), externalCheck: j?.result?.externalCheck }; }, { soft: true });
|
||
const halErr = (await env.ops.get('/ops/api/jobs?kind=site.deploy&limit=3')).json?.jobs?.map((x) => x.error).filter(Boolean)[0] || '';
|
||
if (/Failed to parse URL|내려받기 실패/.test(halErr)) report.bug({ title: 'Agent 가 포털 저장본(bundleUrl, 절대 URL)을 내려받지 못함(HAL 주소를 앞에 덧붙임)', file: 'agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle)', route: 'HAL site.deploy → Agent fetch 단계', expected: 'bundleUrl 이 절대 URL 이면 그대로 내려받는다(서명은 HAL 전용이므로 포털 번들 URL 에는 붙이지 않음)', actual: halErr, repro: '포털 배포 → HAL site.deploy → Agent: "Failed to parse URL from http://<hal>http://<portal>/internal/bundles/…"', flows: [6, 8, 9, 13, 14, 15, 23] });
|
||
const cj0 = (await alice.get(`/api/sites/${site.id}/jobs/${s.list[0]?.detail?.jobId || ''}`)).json; const pj = cj0 && (cj0.job || cj0);
|
||
if (pj && pj.status === 'queued' && pj.attempt > 2) report.bug({ title: '포털 배포 작업이 실패한 HAL 작업을 실패로 보지 않고 5초마다 새 HAL 작업을 무한 재제출(배포가 영원히 "진행 중")', file: 'portal/sites/service.mjs (halJobFor: prev HAL 작업이 failed/cancelled 면 fresh 가 아니어도 새 작업을 만든다 → awaitHal 이 실패를 보지 못함)', route: 'POST /api/sites/:id/deploy', expected: 'HAL 작업이 failed 면 포털 작업도 failed(이전 버전 유지·재시도 버튼), 새 HAL 작업은 사용자 재시도(fresh) 때만', actual: `portal job queued attempt ${pj.attempt} (${pj.waitReason || pj.wait_reason || ''}); HAL 에 같은 사이트의 site.deploy 실패 작업이 attempt 수만큼 쌓임`, repro: 'Agent 가 실패하는 배포를 요청하고 /api/sites/:id/jobs/:id 를 지켜보면 attempt 가 5초마다 증가', flows: [6, 23, 13, 20] });
|
||
// 선행 결함으로 영원히 진행 중인 배포를 사용자 취소·정리해 뒤 흐름(백업·이전·소유권)이 "진행 중 작업" 에 막히지 않게 한다
|
||
await s.run('사용자 취소(진행 중 작업) → 중간 결과 정리·예약 반환', async () => { const jid = s.list[0]?.detail?.jobId; const c = await alice.post(`/api/sites/${site.id}/jobs/${jid}/cancel`); const cl = await alice.post(`/api/sites/${site.id}/cleanup`); const st = (await alice.get(`/api/sites/${site.id}`)).json.site; return { cancel: c.status, cancelError: c.json?.error, cleanup: cl.status, cleanupError: cl.json?.error, siteCount: st.site_count_state, lifecycle: st.lifecycle }; }, { soft: true });
|
||
report.flow(6, 'failed', { steps: s.list, note: e.message, evidence: { halLog: env.hal.proc.lines.filter((l) => /site.deploy/.test(l)).slice(-3) } });
|
||
report.bug({ title: '관리형 배포 종단 흐름 실패(포털→HAL→Agent 저장본 전달)', file: 'agent/client.mjs (download: fetchImpl(base + path)) / agent/jobs/site.mjs (fetchBundle: client.download(p.bundleUrl))', route: 'site.deploy → Agent fetch 단계', expected: '절대 URL(bundleUrl=http://<portal>/internal/bundles/<token>)은 HAL base 를 붙이지 않고 그대로 내려받아야 한다', actual: e.message, repro: '사이트 생성→저장본→POST /api/sites/:id/deploy→HAL site.deploy→Agent site.deploy fetch 단계에서 "HAL 내려받기 실패" 또는 잘못된 URL 오류', flows: [6, 8, 9, 13, 14, 15, 23] });
|
||
} }
|
||
const deployed = deployJob && deployJob.status === 'done';
|
||
// ---- 7 도메인 연결
|
||
{ const s = steps(); try {
|
||
const dm = await s.run('도메인 추가(웹·대표 지정) → 소유 확인 TXT 값 안내 → 작업이 외부 대기(TXT)', async () => { const r = await alice.post('/api/domains', { name: 'aw-e2e.localhost', purpose: 'web', siteId: site.id, makePrimary: true }); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); const txt = r.json.records?.find((x) => x.type === 'TXT'); const j = await waitFor(async () => { const x = await alice.get(`/api/domains/${r.json.domain.id}`); const jj = x.json?.job; return jj && ['awaiting_external', 'failed', 'done', 'needs_user'].includes(jj.status) ? jj : null; }, { timeoutMs: 30000, intervalMs: 1000 }); return { domainId: r.json.domain.id, txtName: txt?.name, txtValueShape: txt?.value ? txt.value.replace(/=.*/, '=…') : null, jobStatus: j?.status, waitReason: j?.waitReason || j?.wait_reason, copyable: !!txt }; });
|
||
await s.run('도메인 목록·상세: 소유 확인·DNS·인증서 상태 표시', async () => { const l = await alice.get('/api/domains'); const d = l.json.domains.find((x) => x.id === dm.domainId); return { ownership: d?.ownership_state, dns: d?.dns_state, cert: d?.cert_state, primary: d?.is_primary }; });
|
||
state.domainId = dm.domainId;
|
||
await s.run('(정리) 도메인 연결 작업 취소 — 포털이 외부 대기 중에도 5초마다 HAL domain.probe 를 새로 만들어 큐를 채우므로(결함 항목) 뒤 흐름을 위해 멈춘다', async () => { const x = await alice.get(`/api/domains/${dm.domainId}`); const jid = x.json?.job?.id; const r = jid ? await alice.post(`/api/domains/${dm.domainId}/jobs/${jid}/cancel`) : null; await sleep(1500); const q = (await env.ops.get('/ops/api/jobs?status=queued,running&kind=domain.probe&limit=50')).json?.jobs?.length; if (q > 3) report.bug({ title: '포털 도메인 연결 작업이 외부 대기(awaiting_external) 중에도 5초마다 HAL domain.probe 작업을 새로 만들어 HAL 큐를 채움', file: 'portal/domains/service.mjs (domain.connect: idempotencyKey 에 attempt·30초 버킷 포함, awaitHal 의 retryLater(5s) 마다 새 작업)', route: 'POST /api/domains → HAL /portal/v1/jobs(domain.probe)', expected: '한 번 확인 요청에 HAL 작업 하나; 재확인은 사용자의 "다시 확인" 또는 완만한 주기', actual: `취소 전 HAL 큐에 domain.probe ${q}개(5초 간격)`, repro: '도메인 추가 뒤 /ops/api/jobs?kind=domain.probe 를 보면 5초마다 새 queued 작업', flows: [7, 22, 30] }); return { cancelled: r?.status, queuedProbes: q }; }, { soft: true });
|
||
report.needsInfra('도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인은 실제 DNS 와 공인 서버가 필요하다(흐름 7·8·11·14·15)');
|
||
report.flow(7, 'partial', { steps: s.list, note: '도메인 추가·복사 가능한 TXT/DNS 값·외부 대기 상태까지 확인. 실 DNS 반영·인증서 발급·대표 도메인 확정은 로컬 확인 불가', evidence: { dm } });
|
||
} catch (e) { report.flow(7, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '도메인 연결 흐름 실패', file: 'portal/domains/api.mjs', route: 'POST /api/domains', expected: 'TXT 안내 + 외부 대기', actual: e.message, repro: '30-sites.mjs 흐름 7', flows: [7] }); } }
|
||
report.flow(8, deployed ? 'partial' : 'not_verifiable_locally', { note: deployed ? '플랫폼 확인 주소(preview.localhost)로 HAL 외부 확인을 시도했으나 로컬에는 80 포트 웹서버가 없어 외부 확인 결과만 기록됨' : '외부 주소 확인은 공인 서버·DNS 필요', blockedBy: deployed ? null : 6, evidence: deployJob ? { externalCheck: deployJob.externalCheck } : {} });
|
||
// ---- 9 사이트 정보·통계
|
||
{ const s = steps(); try {
|
||
const ov = await s.run('개요: 배포 전/후에 따라 준비 단계 또는 실제 통계', async () => { const r = await alice.get(`/api/sites/${site.id}/overview`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const o = r.json.overview; return { kind: o.kind, lifecycle: o.lifecycle, hasStats: o.visits24h != null, readiness: o.readiness || o.stage || null, disk: o.disk ? { used: o.disk.used, limit: o.disk.limit } : null }; });
|
||
const us = await s.run('사용량 탭(배정·실제 사용·마지막 계측)', async () => { const r = await alice.get(`/api/sites/${site.id}/usage`); return { allocations: (r.json.allocations || []).map((a) => a.resource) }; });
|
||
report.flow(9, deployed && ov.hasStats ? 'verified_local' : 'partial', { steps: s.list, note: deployed ? '개요·사용량 조회(Agent stub 가 수집한 값)' : '미배포 사이트는 통계 대신 준비 단계 표시(§8.1) 확인. 실제 접속·네트워크 통계는 배포 뒤 수집', blockedBy: deployed ? null : 6, evidence: { ov, us } });
|
||
} catch (e) { report.flow(9, 'partial', { steps: s.list, note: e.message }); } }
|
||
// ---- 23 실패 작업 재시도·되돌리기(배포 작업 기준)
|
||
{ const s = steps(); try {
|
||
const dep = await s.run('배포 이력: 실패/성공 기록 · 재시도 가능 여부 · 문의용 작업 번호', async () => { const r = await alice.get(`/api/sites/${site.id}/deployments`); const d = r.json.deployments?.[0]; return { status: d?.status, failedStep: d?.result?.failedStep, canRetry: d?.job?.canRetry, jobId: d?.job?.id }; });
|
||
const rt = await s.run('재시도 요청(POST /api/sites/:id/jobs/:jobId/retry)', async () => { if (!dep.jobId) throw new Error('작업 없음'); const r = await alice.post(`/api/sites/${site.id}/jobs/${dep.jobId}/retry`); if (r.status >= 500) report.bug({ title: '취소된 배포 작업의 재시도 요청이 500(서버 오류)으로 응답(작업 상태 오류는 4xx 여야 함)', file: 'portal/sites/api.mjs (jobs/:jobId/:action → lib/jobs JobError 를 HttpError 로 바꾸지 않음)', route: 'POST /api/sites/:id/jobs/:jobId/retry', expected: '409 job_not_retryable + 안내', actual: `${r.status} ${r.json?.error}`, repro: '취소된 작업에 retry', flows: [23] }); return { status: r.status, error: r.json?.error, jobStatus: r.json?.job?.status }; }, { soft: true });
|
||
if (rt && rt.status === 200) await s.run('재시도 작업 결과 대기', async () => { const j = await waitSiteJob(alice, site.id, dep.jobId, 60000); return { status: j?.status, error: j?.error }; }, { soft: true });
|
||
const rb = await s.run('되돌리기 요청(POST /api/sites/:id/rollback) — 이전 버전 없으면 거부', async () => { const r = await alice.post(`/api/sites/${site.id}/rollback`); return { status: r.status, error: r.json?.error, message: r.json?.message }; });
|
||
report.flow(23, deployed ? 'partial' : 'partial', { steps: s.list, note: '실패 기록·재시도 API·되돌리기 거부/허용 조건 확인. 성공한 재배포 뒤 실제 되돌리기는 배포 성공이 필요', blockedBy: deployed ? null : 6, evidence: { dep, rt, rb } });
|
||
} catch (e) { report.flow(23, 'partial', { steps: s.list, note: e.message }); } }
|
||
// ---- 27 마지막 사이트 수 동시 요청(밥의 개인 그룹: 베이직 1개)
|
||
{ const s = steps(); try {
|
||
const bob = state.bob; const personal = (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal'); await bob.post('/api/groups/switch', { groupId: personal.id });
|
||
await s.run('밥 개인 그룹: 베이직(사이트 1) 주문·승인', async () => orderAndApprove(env, bob, { productId: 'basic', key: 'e2e-bob-basic' }).then((r) => ({ granted: r.approve.granted })));
|
||
const [a, b] = await s.run('사이트 2개 생성·저장본', async () => Promise.all([createSiteWithVersion(env, bob, '동시 A'), createSiteWithVersion(env, bob, '동시 B')])).then((x) => x);
|
||
const race = await s.run('두 사이트 동시 최초 배포 요청 → 하나만 200, 다른 쪽 409 precheck_failed(sites 부족)', async () => { const [ra, rb] = await Promise.all([bob.post(`/api/sites/${a.id}/deploy`, {}), bob.post(`/api/sites/${b.id}/deploy`, {})]); const ok = [ra, rb].filter((r) => r.status === 200).length; const bad = [ra, rb].find((r) => r.status !== 200); const good = [ra, rb].find((r) => r.status === 200); if (ok !== 1 || !bad || bad.json?.error !== 'precheck_failed') throw new Error(`${ra.status}/${rb.status} ${bad?.json?.error}`); state.bobDeploy = good ? { siteId: good === ra ? a.id : b.id, jobId: good.json.job?.id } : null; return { ok, blocked: bad.json.error, shortage: bad.json.detail?.items?.find((i) => i.id === 'sites')?.state }; });
|
||
await s.run('(정리) 선행 결함으로 영원히 진행 중인 배포 취소·예약 반환', async () => { if (!state.bobDeploy) return null; await sleep(3000); const c = await bob.post(`/api/sites/${state.bobDeploy.siteId}/jobs/${state.bobDeploy.jobId}/cancel`); const cl = await bob.post(`/api/sites/${state.bobDeploy.siteId}/cleanup`); return { cancel: c.status, cleanup: cl.status, unallocated: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.unallocated }; }, { soft: true });
|
||
state.bobSites = [a, b]; state.bobPersonal = personal.id; report.flow(27, 'verified_local', { steps: s.list, note: '마지막 사이트 수 1개를 두 배포가 동시에 요청 → 하나만 예약 성공, 다른 쪽은 부족 사유(§13.3)', evidence: { race } });
|
||
} catch (e) { report.flow(27, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '동시 예약 흐름 실패', file: 'portal/sites/service.mjs', route: 'POST /api/sites/:id/deploy ×2', expected: '하나만 성공', actual: e.message, repro: '30-sites.mjs 흐름 27', flows: [27] }); } }
|
||
}
|
||
function d_last(s) { return s.list.at(-1); }
|