{name:'hal_skill',risk:'read',description:'HAL 운영 스킬 문서(상태·작업·릴리스·오류 코드의 뜻과 규칙)를 돌려준다.',schema:S({}),run:()=>({skill:SKILL_DOC})},
{name:'hal_health',risk:'read',description:'HAL 자체 건강(ok·version·db·jobs·bootstrap 상태). 비밀 없음.',schema:S({}),run:(d)=>core.selfHealth(d)},
{name:'hal_overview',risk:'read',description:'운영 현황 요약: 구성요소 상태, 서버 수(offline·pending·maintenance·diskWarning), 진행 작업, 열린 사건, 목표·실제 버전 차이(드리프트), DR 준비도, 긴급 복구 세션.',schema:S({}),run:(d)=>overviewLocal(d)},
{name:'hal_servers_list',risk:'read',description:'관리 서버 목록(상태·역할·위치·Agent·용량·지표·모듈 목표/실제 버전).',schema:S({role:str('역할로 거르기(web·mail·backup·control)',{enum:['web','mail','backup','control']})}),run:(d,a)=>({servers:core.listServers(d,{role:a.role||null})})},
{name:'hal_server_get',risk:'read',description:'서버 하나의 상세와 최근 작업(최대 50건).',schema:S({serverId:SERVER_ID},['serverId']),run:(d,a)=>{consts=core.getServer(d,a.serverId);if(!s)thrownotFound('server_not_found','서버가 없습니다');return{server:core.publicServer(d,s),jobs:d.jobs.list({subjectType:'server',subjectId:s.id,limit:50}).map(jobView)};}},
{name:'hal_server_metrics',risk:'read',description:'서버 지표 표본(cpu·메모리·디스크·inode·네트워크). 기본 최근 24시간, 최대 2000개.',schema:S({serverId:SERVER_ID,hours:num('조회 범위(시간, 기본 24, 최대 2160)'),limit:num('최대 표본 수(기본 500, 최대 2000)')},['serverId']),run:(d,a)=>({samples:core.metrics(d,a.serverId,{since:newDate(Date.now()-Math.min(2160,a.hours||24)*3600000).toISOString(),limit:Math.min(2000,a.limit||500)})})},
{name:'hal_releases_list',risk:'read',description:'릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.',schema:S({}),run:(d)=>({releases:core.listReleases(d),running:core.selfHealth(d).bootstrap,modules:DEPLOYABLE_MODULES,deployOrder:DEPLOY_ORDER,targets:d.store.all('SELECT * FROM module_targets ORDER BY module, server_id')})},
{name:'hal_jobs_list',risk:'read',description:'HAL 작업 목록. status 는 쉼표 구분(queued,running,awaiting_external,needs_user,failed …).',schema:S({status:str('상태 필터(쉼표 구분)'),kind:str('종류 필터(예: module.deploy)'),limit:num('최대 건수(기본 100, 최대 500)')}),run:(d,a)=>({jobs:d.jobs.list({status:a.status?String(a.status).split(',').map((x)=>x.trim()).filter(Boolean):null,kind:a.kind||null,limit:Math.min(500,a.limit||100)}).map(jobView)})},
{name:'hal_job_get',risk:'read',description:'작업 하나의 단계·기록·결과·자식 요약·Agent 작업.',schema:S({jobId:str('작업 id(j_…)')},['jobId']),run:(d,a)=>{constj=d.jobs.get(a.jobId);if(!j)thrownotFound('job_not_found','작업이 없습니다');return{job:{...jobView(j),result:redact(j.result),log:j.log},children:d.jobs.childrenSummary(j.id),agentJobs:d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at',j.id)};}},
{name:'hal_incidents_list',risk:'read',description:'사건 목록(열린 것 먼저, 최대 200)과 최근 알림.',schema:S({openOnly:bool('열린 사건만')}),run:(d,a)=>({incidents:d.store.all(`SELECT * FROM incidents ${a.openOnly?'WHERE resolved_at IS NULL ':''}ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200`).map((i)=>({...i,notes:parseJson(i.notes_json,[])}))})},
{name:'hal_audit_list',risk:'read',description:'감사 기록 조회(행동 접두어·대상·기간). 비밀값은 [redacted].',schema:S({action:str('행동 접두어(예: server., deploy., ai.)'),subjectType:str('대상 종류'),subjectId:str('대상 id'),since:str('ISO 시각 이후'),limit:num('최대 건수(기본 100, 최대 500)')}),run:(d,a)=>({entries:listAudit(d.store,{action:a.action,subjectType:a.subjectType,subjectId:a.subjectId,since:a.since,limit:Math.min(500,a.limit||100)})})},
{name:'hal_mail_services_list',risk:'read',description:'HAL 관리형 메일 서비스 등록부 readback(중복·비밀 누출 검사 포함, 값 없음).',schema:S({}),run:(d)=>mailsvc.readback(d)},
{name:'hal_dr_status',risk:'read',description:'재해복구 준비도·스냅샷·복원·훈련 목록.',schema:S({}),run:(d)=>({readiness:dr.readiness(d),snapshots:dr.listSnapshots(d),restores:d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'),drills:d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50')})},
{name:'hal_action_status',risk:'read',description:'승인 요청(제안) 하나의 상태·결과를 본다(proposed·executing·executed·failed·rejected·cancelled). 쓰기·고위험 도구를 부른 뒤 운영자 승인 결과를 확인할 때 쓴다.',schema:S({actionId:str('제안 id(aia_…)')},['actionId']),run:(d,a)=>{constx=d.ai&&d.ai.getAction(a.actionId);if(!x)thrownotFound('action_not_found','제안이 없습니다');return{action:x};}},
{name:'hal_actions_pending',risk:'read',description:'운영자 승인을 기다리는 제안 목록(모든 출처).',schema:S({}),run:(d)=>({actions:d.ai?d.ai.listActions({status:'proposed',limit:100}):[]})},
{name:'hal_changelog',risk:'read',description:'VERSION_HISTORY.md 의 버전별 변경 이력(버전을 주면 그 절만).',schema:S({version:str('버전(예: 0.3.0)')}),run:(d,a)=>{constc=changelog();returna.version?{version:a.version,text:c[a.version]||null}:{versions:Object.keys(c)};}},
{name:'hal_server_approve',risk:'write',description:'Agent 가 연결된 pending 서버를 승인해 배치 가능(ready)으로 만든다.',schema:S({serverId:SERVER_ID},['serverId']),identifiers:(a)=>[a.serverId],run:(d,a,actor)=>({server:core.publicServer(d,core.approveServer(d,a.serverId,actor.operatorId))})},
{name:'hal_server_maintenance',risk:'write',description:'서버 유지보수 시작(신규 배치 중지) 또는 종료(end=true).',schema:S({serverId:SERVER_ID,end:bool('true 면 유지보수 종료'),note:str('유지보수 메모'),acceptsNew:bool('종료 때 신규 배치 허용(기본 true)')},['serverId']),identifiers:(a)=>[a.serverId],run:(d,a,actor)=>{if(!core.getServer(d,a.serverId))thrownotFound('server_not_found','서버가 없습니다');consts=core.updateServer(d,a.serverId,a.end?{status:'ready',accepts_new:a.acceptsNew===false?0:1,maintenance_note:null}:{status:'maintenance',accepts_new:0,maintenance_note:a.note||''},actor.operatorId);return{server:core.publicServer(d,s)};}},
{name:'hal_incident_open',risk:'write',description:'사건을 등록한다(같은 key 의 열린 사건이 있으면 병합).',schema:S({key:str('사건 key(같은 원인 묶음)'),title:str('제목'),severity:str('심각도',{enum:['info','warning','critical']}),component:str('구성요소'),customerImpact:str('고객 영향(없으면 생략)')},['key','title']),run:(d,a,actor)=>{constopen=d.store.get('SELECT id FROM incidents WHERE key=? AND resolved_at IS NULL',a.key);if(open){d.store.run('UPDATE incidents SET severity=?, title=?, customer_impact=?, updated_at=? WHERE id=?',a.severity||'warning',a.title,a.customerImpact||null,nowIso(),open.id);return{id:open.id,merged:true};}constid=newId('inc_');d.store.run('INSERT INTO incidents(id, key, severity, title, component, customer_impact, started_at, assignee, public, public_note, notes_json, updated_at) VALUES(?,?,?,?,?,?,?,?,0,NULL,\'[]\',?)',id,a.key,a.severity||'warning',a.title,a.component||null,a.customerImpact||null,nowIso(),actor.operatorId,nowIso());return{id,merged:false};}},
{name:'hal_incident_resolve',risk:'write',description:'사건을 종료한다. 실제 사용자 경로 확인(verifiedUserPath) 없이는 거부된다(§31.2).',schema:S({incidentId:str('사건 id(inc_…)'),verifiedUserPath:str('실제 확인한 사용자 경로(예: https://… 200)'),note:str('메모')},['incidentId','verifiedUserPath']),identifiers:(a)=>[a.incidentId],run:(d,a,actor)=>{constinc=d.store.get('SELECT * FROM incidents WHERE id=?',a.incidentId);if(!inc)thrownotFound('not_found','사건이 없습니다');if(!a.verifiedUserPath)thrownewHttpError(400,'verify_required','실제 사용자 경로 확인 뒤에 종료할 수 있습니다(§31.2)');constnotes=parseJson(inc.notes_json,[]);notes.push({at:nowIso(),by:actor.operatorId,note:a.note||`사용자 경로 확인: ${a.verifiedUserPath}`});d.store.run('UPDATE incidents SET notes_json=?, resolved_at=?, verified_at=?, updated_at=? WHERE id=?',toJson(notes),nowIso(),nowIso(),nowIso(),inc.id);return{id:inc.id,resolved:true};}},
{name:'hal_module_health',risk:'write',description:'모듈 건강 확인 작업(module.health)을 큐에 넣는다(대상 서버 생략 시 전체).',schema:S({module:str('모듈 이름',{enum:Object.keys(MODULES)}),serverIds:arr('대상 서버 id 목록')},['module']),run:(d,a,actor)=>({job:jobView(d.jobs.enqueue({kind:'module.health',subject:{module:a.module,serverIds:a.serverIds||null},createdBy:actor.operatorId,actorType:'operator',subjectType:'module',subjectId:a.module,label:`${a.module} 건강 확인`}).job)})},
{name:'hal_dr_snapshot',risk:'write',description:'HAL 재해복구 스냅샷을 지금 만든다(manual).',schema:S({}),run:(d,a,actor)=>({snapshot:dr.snapshot(d,{kind:'manual',by:actor.operatorId})})},
// ---------------- 고위험(high): 승인 + 재인증 + 3단계 확인
{name:'hal_server_isolate',risk:'high',description:'서버를 격리(isolated)한다 — 작업 배정이 멈춘다. 되돌리려면 운영자가 상태를 바꿔야 한다.',schema:S({serverId:SERVER_ID,reason:str('격리 사유')},['serverId','reason']),identifiers:(a)=>[a.serverId],run:(d,a,actor)=>{if(!core.getServer(d,a.serverId))thrownotFound('server_not_found','서버가 없습니다');return{server:core.publicServer(d,core.updateServer(d,a.serverId,{status:'isolated',isolated_reason:a.reason},actor.operatorId))};}},
{name:'hal_server_delete',risk:'high',description:'서버를 삭제하고 Agent 를 해지한다. 서비스가 배치된 서버는 force 없이는 거부된다(먼저 이전).',schema:S({serverId:SERVER_ID,force:bool('배치된 서비스가 있어도 삭제')},['serverId']),identifiers:(a)=>[a.serverId],run:(d,a,actor)=>{consts=core.getServer(d,a.serverId);if(!s)thrownotFound('server_not_found','서버가 없습니다');consthosted=(s.capacity&&s.capacity.sitesHosted)||0;if(hosted>0&&!a.force)thrownewHttpError(409,'server_in_use',`이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`);if(s.agent_id)core.revokeAgent(d,s.agent_id,actor.operatorId);d.store.run('DELETE FROM servers WHERE id=?',s.id);d.store.run('DELETE FROM module_targets WHERE server_id=?',s.id);return{deleted:s.id};}},
{name:'hal_module_deploy',risk:'high',description:'승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.',schema:S({module:str('모듈 이름(HAL 은 배포 대상 아님)',{enum:DEPLOYABLE_MODULES}),version:str('릴리스 버전'),serverIds:arr('대상 서버 id 목록'),nonce:str('멱등 구분값(같은 배포를 다시 넣을 때)')},['module','version','serverIds']),identifiers:(a)=>a.serverIds||[],run:(d,a,actor)=>{constr=d.jobs.enqueue({kind:'module.deploy',subject:{module:a.module,version:a.version,serverIds:a.serverIds||[]},idempotencyKey:`module.deploy:${a.module}:${a.version}:${(a.serverIds||[]).slice().sort().join(',')}:${a.nonce||''}`,createdBy:actor.operatorId,actorType:'operator',subjectType:'module',subjectId:a.module,label:`${a.module}${a.version} 배포`});return{job:jobView(r.job),created:r.created};}},
{name:'hal_module_rollback',risk:'high',description:'모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.',schema:S({module:str('모듈 이름',{enum:DEPLOYABLE_MODULES}),toVersion:str('되돌릴 버전'),serverIds:arr('대상 서버 id 목록')},['module','serverIds']),identifiers:(a)=>a.serverIds||[],run:(d,a,actor)=>({job:jobView(d.jobs.enqueue({kind:'module.rollback',subject:{module:a.module,toVersion:a.toVersion||null,serverIds:a.serverIds||[]},createdBy:actor.operatorId,actorType:'operator',subjectType:'module',subjectId:a.module,label:`${a.module} 되돌리기`}).job)})},
{name:'hal_module_reconcile',risk:'high',description:'목표 버전과 실제 버전이 다른 대상만 다시 설치하는 작업(module.reconcile)을 큐에 넣는다.',schema:S({module:str('모듈 이름(생략 시 전체)',{enum:Object.keys(MODULES)})}),identifiers:(a)=>[a.module||'all'],run:(d,a,actor)=>({job:jobView(d.jobs.enqueue({kind:'module.reconcile',subject:{module:a.module||null},createdBy:actor.operatorId,actorType:'operator',label:'목표·실제 상태 맞추기'}).job)})},
{name:'hal_release_approve',risk:'high',description:'등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).',schema:S({version:str('릴리스 버전')},['version']),identifiers:(a)=>[a.version],run:(d,a,actor)=>({release:core.approveRelease(d,a.version,actor.operatorId)})},
{name:'hal_job_revert',risk:'high',description:'끝난 작업을 되돌린다(revert 를 지원하는 종류만).',schema:S({jobId:str('작업 id')},['jobId']),identifiers:(a)=>[a.jobId],run:async(d,a,actor)=>({job:jobView(awaitd.jobs.revert(a.jobId,actor.operatorId))})},
{name:'hal_config_set',risk:'high',description:'HAL 운영 설정(공개 주소·포털 주소·offline 판정 시간·지표 보존 일수)을 바꾼다. 비밀값은 받지 않는다. 서비스 재시작 뒤 반영되는 값도 있다.',schema:S({publicUrl:str('HAL 공개 주소'),portalUrl:str('포털 내부 주소'),offlineAfterMs:num('heartbeat 없음 → offline 판정(ms)'),metricsRetentionDays:num('지표 보존 일수(최소 90)')}),identifiers:()=>['hal'],run:(d,a,actor)=>{constpatch={};for(constkof['publicUrl','portalUrl','offlineAfterMs','metricsRetentionDays'])if(a[k]!==undefined)patch[k]=a[k];if(!Object.keys(patch).length)thrownewHttpError(400,'nothing_to_set','바꿀 값이 없습니다');d.config.set(patch,actor.operatorId);return{updated:Object.keys(patch)};}},
if(p.type==='string'){if(typeofv!=='string')return`${k} 는 문자열이어야 합니다`;if(v.length>4000)return`${k} 가 너무 깁니다`;if(p.enum&&!p.enum.includes(v))return`${k} 는 ${p.enum.join('·')} 중 하나여야 합니다`;}
elseif(p.type==='number'){if(typeofv!=='number'||!Number.isFinite(v))return`${k} 는 숫자여야 합니다`;}
elseif(p.type==='boolean'){if(typeofv!=='boolean')return`${k} 는 true/false 여야 합니다`;}
elseif(p.type==='array'){if(!Array.isArray(v))return`${k} 는 배열이어야 합니다`;if(v.length>200)return`${k} 항목이 너무 많습니다`;if(p.items?.type==='string'&&!v.every((x)=>typeofx==='string'&&x.length<=400))return`${k} 항목은 문자열이어야 합니다`;}
}
returnnull;
}
/** 도구 실행(검증 뒤). 결과는 redact 를 거친다. actor = { operatorId, label } */