report.flow(22,'partial',{note:'시작 전 진단',evidence:{stuckBefore}});
// ---- 22 HAL 우선 배포
{consts=steps();try{
constrel=awaits.run('릴리스 화면: 버전·main 커밋·승인 상태·HAL 실행 버전·부트스트랩(최초 Agent)·배포 순서',async()=>{constr=awaitenv.ops.get('/ops/api/releases');consth=awaitenv.ops.get('/api/health');return{releases:r.json.releases.map((x)=>x.version+':'+x.status),running:h.json.bootstrap,order:r.json.deployOrder};});
consthf=awaits.run('HAL 이 아직 0.1.2 가 아닐 때 다른 모듈 0.1.2 배포 → hal_first 거부',async()=>{constr=awaitenv.ops.post('/ops/api/modules/portal/deploy',{version:'0.1.2',serverIds:[ctl.serverId],nonce:'e2e-hf'});constj=awaitwaitOpsJob(env,r.json.job.id,20000);return{status:j?.job?.status,error:j?.job?.errorCode||j?.job?.error};});
constna=awaits.run('승인되지 않은 릴리스는 거부 / 수동 설치 잠김 표시',async()=>{consth=awaitenv.ops.get('/api/health');return{manualInstallLocked:h.json.bootstrap.manualInstallLocked,complete:h.json.bootstrap.complete};});
constdep=awaits.run('HAL 모듈 0.1.1 을 제어 서버에 배포(실제 Agent module.install: artifact 내려받기·sha256·releases/current·유닛(stub)·실제 버전 확인)',async()=>{constr=awaitenv.ops.post('/ops/api/modules/hal/deploy',{version:'0.1.1',serverIds:[ctl.serverId],nonce:'e2e-hal'});constj=awaitwaitOpsJob(env,r.json.job.id,10000);constx=awaitenv.ops.get(`/ops/api/jobs/${r.json.job.id}`);return{status:x.json?.job?.status,steps:x.json?.job?.steps?.map((y)=>y.id+':'+y.state),error:x.json?.job?.error,children:x.json?.children?.children?.map((c)=>c.status+':'+(c.error||'').slice(0,120)),agentJobs:x.json?.agentJobs?.map((a)=>a.kind+':'+a.status+':'+(a.error||'').slice(0,120)),timedOut:!!j?.timedOut};});
if(dep.timedOut)report.bug({title:'HAL 작업 엔진이 작업을 한 번에 하나씩만 실행(tick 이 execute 를 await)해 느린 작업이 큐 전체를 막고, module.deploy 부모가 자식 완료를 기다리면 자식이 실행될 수 없어 교착',file:'lib/jobs.mjs (createJobEngine.start/tick) / hal/jobs.mjs (fanout 이 자식 완료를 폴링)',route:'POST /ops/api/modules/:name/deploy',expected:'엔진은 running 수가 concurrency 미만이면 실행을 분리(detach)해 여러 작업을 동시에 진행해야 한다(부모가 자식을 기다려도 자식이 실행됨)',actual:dep,repro:'(1) Agent 가 수십 초 걸리는 domain.probe 가 queued 로 쌓이면 다른 모든 HAL 작업이 기다린다 (2) 큐가 비어도 모듈 배포는 부모 collect:running + 자식 queued 로 영원히 멈춘다(test/hal.test.mjs 는 pump() 로 tick 을 동시에 돌려 통과)',flows:[22,30]});
elseif(dep.status!=='done')report.bug({title:'모듈 설치가 ARIWEB_SRV_ROOT 를 무시하고 절대 경로(/srv/ariweb/<module>)에 쓰려 해 일반 계정·검증 환경에서 실패',file:'hal/jobs.mjs (module.deploy.target: appDir 기본값) / agent/jobs/module.mjs',route:'POST /ops/api/modules/:name/deploy',expected:'Agent 가 payload.appDir 가 없거나 루트 override 가 있으면 roots.srv 아래에 설치',actual:dep,repro:'ARIWEB_SRV_ROOT 를 둔 Agent 에 module.deploy → mkdir /srv/ariweb/hal 권한 오류',flows:[22,30]});
constd=awaits.run('agent 모듈을 두 서버 + 없는 서버에 배포 → 대상별 자식 작업·일부 실패를 성공으로 묶지 않음(partial/all_failed)',async()=>{constr=awaitenv.ops.post('/ops/api/modules/agent/deploy',{version:'0.1.1',serverIds:[web.serverId,bk.serverId,'srv_missing_e2e'],nonce:'e2e-partial'});constj=awaitwaitOpsJob(env,r.json.job.id,10000);constx=awaitenv.ops.get(`/ops/api/jobs/${r.json.job.id}`);return{status:x.json?.job?.status,errorCode:x.json?.job?.errorCode,overall:x.json?.children?.overall,children:x.json?.children?.children?.map((c)=>(c.subject?.serverId||'')+':'+c.status+':'+(c.error||'').slice(0,80)),timedOut:!!j?.timedOut};});
consth=awaits.run('모듈 건강 확인 작업·목표/실제 버전 차이(drift) 표시·다시 맞추기 작업',async()=>{constr=awaitenv.ops.post('/ops/api/modules/agent/health',{serverIds:[web.serverId]});constj=awaitwaitOpsJob(env,r.json.job.id,5000);constov=awaitenv.ops.get('/ops/api/overview');constrc=awaitenv.ops.post('/ops/api/modules/reconcile',{module:'agent'});return{health:j?.job?.status||'timeout',drift:ov.json.drift?.length,reconcile:rc.status,overviewComponents:ov.json.components?.map((c)=>c.name+':'+c.status)};});
constrb=awaits.run('되돌리기는 3단계 확인(영향 범위·식별정보·최종 실행) 없이는 거부',async()=>{consta=awaitenv.ops.post('/ops/api/modules/agent/rollback',{serverIds:[web.serverId]});constb=awaitenv.ops.post('/ops/api/modules/agent/rollback',{serverIds:[web.serverId],confirmImpact:true,confirmIdentifiers:['wrong']});return{step1:a.json?.error,step2:b.json?.error};});
report.flow(30,okAll?'verified_local':'failed',{steps:s.list,note:okAll?'대상별 자식 작업·일부 실패 partial 요약(전체 성공으로 묶지 않음)·건강 확인·drift 조정·되돌리기 3단계 확인 거부':'일부 실패 요약·건강 확인·drift 조정 또는 되돌리기 확인 규칙이 기대와 다름(증거 참조)',evidence:{d,h,rb}});
constrs=awaits.run('복원 요청 → 승인 전 시작 거부 → 승인 → 기존 제어 중지 확인 없이는 거부 → 시작(명령 안내) → 확인 항목 부족 거부 → 완료',async()=>{constsid=(awaitenv.ops.get('/ops/api/dr')).json.snapshots[0].id;constrq=awaitenv.ops.post('/ops/api/dr/restores',{snapshotId:sid,note:'e2e 훈련'});constid=rq.json.restore.id;constearly=awaitenv.ops.post(`/ops/api/dr/restores/${id}/start`,{oldControlStopped:true,confirmText:'기존 제어 위치를 중지했습니다'});awaitenv.ops.post(`/ops/api/dr/restores/${id}/approve`);constnoStop=awaitenv.ops.post(`/ops/api/dr/restores/${id}/start`,{oldControlStopped:false});constst=awaitenv.ops.post(`/ops/api/dr/restores/${id}/start`,{oldControlStopped:true,confirmText:'기존 제어 위치를 중지했습니다'});constinc=awaitenv.ops.post(`/ops/api/dr/restores/${id}/finish`,{checks:{operatorLogin:true}});constfin=awaitenv.ops.post(`/ops/api/dr/restores/${id}/finish`,{checks:{operatorLogin:true,halControl:true,https:true,customerService:true}});return{early:early.json?.error,noStop:noStop.json?.error,command:!!st.json?.command,incomplete:inc.json?.error,final:fin.json?.restore?.status};});
constdrill=awaits.run('복구 훈련: 항목 하나 실패 → failed, 모두 성공·측정 RTO/RPO → 준비 완료',async()=>{constsid=(awaitenv.ops.get('/ops/api/dr')).json.snapshots[0].id;constd1=awaitenv.ops.post('/ops/api/dr/drills',{snapshotId:sid});constf1=awaitenv.ops.post(`/ops/api/dr/drills/${d1.json.drill.id}/finish`,{steps:{hal:true,operatorLogin:true,vault:true,certBase:true,httpsCustomer:false},measuredRtoMinutes:20,measuredRpoMinutes:10});constd2=awaitenv.ops.post('/ops/api/dr/drills',{});constf2=awaitenv.ops.post(`/ops/api/dr/drills/${d2.json.drill.id}/finish`,{steps:{hal:true,operatorLogin:true,vault:true,certBase:true,httpsCustomer:true},measuredRtoMinutes:20,measuredRpoMinutes:10});constrd=(awaitenv.ops.get('/ops/api/dr')).json.readiness;return{first:f1.json?.drill?.result,second:f2.json?.drill?.result,ready:rd.ready,measured:rd.lastDrill?{rto:rd.lastDrill.measured_rto_minutes,rpo:rd.lastDrill.measured_rpo_minutes}:null};});
report.needsInfra('재해복구의 실제 복원(hal-restore.sh 로 다른 호스트에 HAL 재기동)·실제 운영자 로그인·HTTPS·고객 서비스 외부 확인은 실 서버가 필요하다(흐름 25 기록·훈련 절차만 로컬 확인)');
report.flow(25,'partial',{steps:s.list,note:'DR 설정(RTO/RPO/주기/보존)·사본 생성·검증·외부 보관·복원 승인/기존 제어 중지 확인/완료 항목·훈련 실패/성공→준비 완료 기록까지 확인. 실제 다른 호스트 복원은 실 서버 필요',evidence:{r0,cfg,snap,rs,drill}});
}catch(e){report.flow(25,'failed',{steps:s.list,note:e.message});report.bug({title:'재해복구 운영 API 흐름 실패',file:'hal/dr.mjs',route:'/ops/api/dr/*',expected:'설정→사본→복원 기록→훈련',actual:e.message,repro:'80-hal.mjs 흐름 25',flows:[25]});}}
// ---- 31 긴급 복구
{consts=steps();try{
consthealthy=awaits.run('HAL 정상 상태에서 선언 거부(hal_healthy)',async()=>{constr=awaitenv.ops.post('/ops/api/emergency',{incidentKey:'INC-E2E',reason:'HAL DB 손상으로 작업 엔진 사용 불가',scope:['hal','hal_data']});returnr.json?.error;});
constsess=awaits.run('critical 사건 등록 → 선언 → 승인 부족 자격 거부 → 3단계(순서·식별정보·문구) 거부/승인 → 자격 발급(1회)',async()=>{awaitenv.ops.post('/ops/api/incidents',{key:'INC-E2E',severity:'critical',title:'HAL 장애(e2e)',customerImpact:'없음'});constd=awaitenv.ops.post('/ops/api/emergency',{incidentKey:'INC-E2E',reason:'HAL DB 손상으로 작업 엔진 사용 불가',scope:['hal','hal_data']});if(d.status!==200)thrownewError(d.text.slice(0,200));constid=d.json.session.id;constnoCred=awaitenv.ops.post(`/ops/api/emergency/${id}/credential`);constwrongStep=awaitenv.ops.post(`/ops/api/emergency/${id}/approve`,{step:2,identifiers:['hal']});awaitenv.ops.post(`/ops/api/emergency/${id}/approve`,{step:1,impactText:'HAL 제어 평면 중단, 고객 서비스는 유지'});constbadIds=awaitenv.ops.post(`/ops/api/emergency/${id}/approve`,{step:2,identifiers:['hal']});awaitenv.ops.post(`/ops/api/emergency/${id}/approve`,{step:2,identifiers:['hal_data','hal']});constbadText=awaitenv.ops.post(`/ops/api/emergency/${id}/approve`,{step:3,finalConfirm:'아무거나',revertible:true});constok=awaitenv.ops.post(`/ops/api/emergency/${id}/approve`,{step:3,finalConfirm:'복구 실행 INC-E2E',revertible:true});constcred=awaitenv.ops.post(`/ops/api/emergency/${id}/credential`);return{id,noCred:noCred.json?.error,wrongStep:wrongStep.json?.error,badIds:badIds.json?.error,badText:badText.json?.error,approved:ok.json?.session?.status,approvals:ok.json?.session?.approvals?.length,credential:cred.json?.credential,expiresAt:cred.json?.expiresAt};});
constperf=awaits.run('일반 배포(module.deploy) 거부 · 범위 밖 거부 · 잘못된 자격 거부 · 허용 행동 수행 · 확인 뒤 자격 폐기 · 폐기 뒤 거부 · 조정 · 종료',async()=>{constid=sess.id;constc=sess.credential;constforb=awaitenv.ops.post(`/ops/api/emergency/${id}/perform`,{credential:c,action:'module.deploy'});constoos=awaitenv.ops.post(`/ops/api/emergency/${id}/perform`,{credential:c,action:'cert.reissue_internal'});constbad=awaitenv.ops.post(`/ops/api/emergency/${id}/perform`,{credential:'bad',action:'hal.restart'});constok=awaitenv.ops.post(`/ops/api/emergency/${id}/perform`,{credential:c,action:'vault.verify'});constv=awaitenv.ops.post(`/ops/api/emergency/${id}/verify`,{verify:{vaultOk:true}});constafter=awaitenv.ops.post(`/ops/api/emergency/${id}/perform`,{credential:c,action:'vault.verify'});constrc=awaitenv.ops.post(`/ops/api/emergency/${id}/reconcile`);constcl=awaitenv.ops.post(`/ops/api/emergency/${id}/close`,{note:'e2e'});return{forbidden:forb.json?.error,outOfScope:oos.json?.error,badCred:bad.json?.error,performed:ok.status,revoked:!!v.json?.session?.credential_revoked_at,afterRevoke:after.json?.error,reconcile:rc.status,closed:cl.json?.session?.status};});
report.flow(31,'partial',{steps:s.list,note:'정상 상태 선언 거부·3단계 승인(순서/식별정보/문구 거부)·자격 1회 발급·일반 배포/범위 밖/잘못된 자격 거부·수행·폐기 뒤 거부·조정·종료 확인. 자격 만료 거부는 유효 시간(분 단위 설정) 때문에 로컬 실행에서 생략(단위 테스트 test/hal.test.mjs 로 확인)',evidence:{healthy,sess:{...sess,credential:undefined},perf}});
constduring=awaits.run('HAL 중단 중: 기존 조회(사이트 목록·개요)는 제공, 배포 전 확인/배포는 정보 확인 지연·차단',async()=>{constl=awaitclient.get('/api/sites');constov=awaitclient.get(`/api/sites/${sid}/overview`);constpc=awaitclient.get(`/api/sites/${sid}/precheck`);constdep=awaitclient.post(`/api/sites/${sid}/deploy`,{});return{list:l.status,overview:ov.status,precheck:{status:pc.status,blocks:(pc.json?.precheck?.items||[]).filter((i)=>i.state==='block').map((i)=>i.id+':'+(i.text||'').slice(0,40))},deploy:{status:dep.status,error:dep.json?.error,message:dep.json?.message}};});
constdash=awaits.run('대시보드/알림에 중앙 상태 확인 지연 표시(가능하면)',async()=>{constd=awaitclient.get('/api/dashboard');return{status:d.status,halState:d.json?.summary?.hal||d.json?.platform||d.json?.stale||null};},{soft:true});
constback=awaits.run('HAL 재기동(같은 호스트의 죽은 인스턴스 임대 이어받기) → 서버·Agent 상태 복귀',async()=>{consth=awaitenv.hal.restart();if(!h||h.timedOut){consth2=awaitenv.hal.restart({takeover:true});if(!h2||h2.timedOut)thrownewError('HAL 재기동 실패: '+env.hal.proc.lines.slice(-4).join(' | '));return{takeoverNeeded:true,bootstrap:h2.bootstrap?.complete};}awaitenv.loginOps();constsrv=awaitwaitFor(async()=>{constr=awaitenv.ops.get('/ops/api/servers');returnr.json?.servers?.filter((x)=>x.status==='ready').length>=4?r:null;},{timeoutMs:15000,intervalMs:500});return{takeoverNeeded:false,bootstrap:h.bootstrap?.complete,readyServers:srv.json?.servers?.filter((x)=>x.status==='ready').length};});
report.flow(29,'verified_local',{steps:s.list,note:'HAL 중단 중 포털 조회 유지·신규 배포 차단(정보 확인 지연), HAL 재기동 뒤 서버·Agent 복귀',evidence:{during,dash,back}});
constbefore=awaits.run('재시작 전: 포털 진행 작업(메일 활성화 외부 대기)·HAL 작업 목록',async()=>{constm=state.mailServiceId?awaitalice.get(`/api/mail/services/${state.mailServiceId}/job`):null;constj=awaitenv.ops.get('/ops/api/jobs?limit=50');return{mailJob:m?.json?.job?{id:m.json.job.id,status:m.json.job.status}:null,halJobs:j.json?.jobs?.length};});
constpr=awaits.run('포털 재시작 → 재로그인 없이 세션 유지·같은 작업 id·상태 유지',async()=>{awaitenv.portal.restart();constme=awaitalice.get('/api/auth/me');constm=state.mailServiceId?awaitalice.get(`/api/mail/services/${state.mailServiceId}/job`):null;return{sessionKept:!!me.json?.user,mailJob:m?.json?.job?{id:m.json.job.id,status:m.json.job.status}:null,same:m?.json?.job?.id===before.mailJob?.id};});
consthr=awaits.run('HAL 재시작 → 운영자 세션·작업 목록·서버 등록 유지',async()=>{awaitenv.hal.restart();constme=awaitenv.ops.get('/ops/api/me');constj=awaitenv.ops.get('/ops/api/jobs?limit=50');return{opsSessionKept:!!me.json?.operator,halJobs:j.json?.jobs?.length,sameCount:j.json?.jobs?.length===before.halJobs};});
report.flow(26,pr.sessionKept&&pr.same?'verified_local':'partial',{steps:s.list,note:'포털·HAL 재시작 뒤 세션·진행 작업(외부 대기)·작업 목록 유지 확인. 진행 중(running) 배포의 단계 재개는 배포 성공(흐름 6) 뒤 확인 필요',evidence:{before,pr,hr}});