HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
/**
* test/hal-0317.test.mjs — Ariweb Suite 0.3.17(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.17.md`, Oz 2026-10-29).
*
* 계약(요약)
* 1) observability → HawkEye(`hawkeye`): 모듈 기술서·배포 순서·운영 현황 구성요소 이름 변경, 옛 이름은 별칭으로 받고 DB 행은 마이그레이션으로 바꾼다.
* 2) 역할별 통합 배포(`server.provision`): 프로필(webmaker·webhosting·arimail·backup·portal)에 따라 필요한 모듈을 한 번에 순서대로 배포한다. 포털은 하나만(이미 있으면 설치 거부, 이전 작업 `portal.relocate`).
* 서버 슬롯(slots)을 두고 배치(pickServer)가 여유 슬롯을 본다.
* 3) 화면 갱신: 주기 갱신은 바뀐 부분만 바꾼다(순수 diff + ops.js 정적 계약).
* 4) 배포 릴리스: HAL 은 배포 대상이 아니다(`hal_not_deployable`), HAL 먼저 규칙은 "HAL 이 릴리스보다 오래되지 않으면" 통과. 기본 화면은 모듈별 최신 릴리스·설치 안내·상호 연결·배치 상태, 과거 버전은 접어서.
* 5) 작업 진행 창(dock): 공통 틀 아래에 진행 중·히스토리.
* 6) 배포 점검: 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 풀고, 하나도 없으면 API 에서 바로 `no_targets`.
* 7) 대상 서버 목록·선택(라디오/체크) 렌더러.
*/
import { test , eq , ok , tmpDir , done , sleep } from '../lib/test.mjs' ;
import { invoke } from '../lib/http.mjs' ;
import { createHalDeps } from '../hal/deps.mjs' ;
import { buildApp } from '../hal/app.mjs' ;
import * as core from '../hal/core.mjs' ;
import * as ops from '../hal/ops.mjs' ;
import { MODULES , DEPLOY_ORDER , DEPLOYABLE_MODULES , descriptor , SERVER_PROFILES } from '../hal/modules/index.mjs' ;
import { MIGRATIONS } from '../hal/schema.mjs' ;
import * as R from '../hal/ui/render.mjs' ;
import { mkdirSync , readFileSync , writeFileSync } from 'node:fs' ;
import { join , dirname } from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
import { randomBytes , createHash } from 'node:crypto' ;
const ROOT = join ( dirname ( fileURLToPath ( import . meta . url )), '..' );
const rnd = ( p = 'fx' ) => ` ${ p } - ${ randomBytes ( 12 ). toString ( 'hex' ) } ` ;
function makeHal () {
const dataDir = join ( tmpDir (), 'hal' ); mkdirSync ( dataDir , { recursive : true });
const config = { dataDir , file : join ( dataDir , 'config.json' ), port : 0 , host : '127.0.0.1' , publicUrl : 'https://hal.example.test' , portalUrl : 'http://127.0.0.1:1' , opsAllowCidrs : [ '127.0.0.1/32' ], metricsRetentionDays : 90 , offlineAfterMs : 90000 , transferTtlMs : 3600000 , emergencyTtlMinutes : 30 , agentPollMs : 20 , agentWaitTimeoutMs : 4000 , fanoutTimeoutMs : 6000 , values : {}, set ( p ) { Object . assign ( this , p ); } };
const d = createHalDeps ({ config , dbFile : ':memory:' , fetchImpl : async () => ({ ok : true , status : 200 , text : async () => 'ok' , json : async () => ({ ok : true }) }) }); d . jobsRunning = true ;
return { d , app : buildApp ( d ), dataDir };
}
async function loginOp ( h ) { const pw = rnd ( 'pw' ); ops . createAccount ( h . d , { login : 'ozops' , password : pw , displayName : '오즈' }); const r = await invoke ( h . app , 'POST' , '/ops/api/login' , { body : { login : 'ozops' , password : pw } }); ok ( r . json . ok ); return { pw , as : ( m , p , b ) => invoke ( h . app , m , p , { body : b , cookies : { aw_ops : r . cookies . aw_ops }, headers : { 'x-csrf' : r . json . csrf } }) }; }
/** 서버 + Agent(등록·승인·heartbeat) */
async function addServer ( h , { name , role , sitesHosted = 0 }) {
const s = core . createServer ( h . d , { name , role }); const t = core . issueRegistrationToken ( h . d , { kind : 'managed' , serverId : s . id });
const r = await invoke ( h . app , 'POST' , '/agent/v1/register' , { body : { token : t . token , facts : { hostname : name , nginx : true }, version : '0.1.1' } }); ok ( r . json . ok , JSON . stringify ( r . json ));
h . d . store . run ( 'UPDATE servers SET last_seen_at=?, capacity_json=? WHERE id=?' , new Date (). toISOString (), JSON . stringify ({ diskFreeBytes : 50e9 , diskTotalBytes : 100e9 , sitesHosted , reservedBytes : 0 }), s . id ); core . approveServer ( h . d , s . id , 'op' );
return { serverId : s . id , agentId : r . json . agentId };
}
function bootstrapDone ( d , version ) { core . claimLive ( d , { instanceId : d . instanceId , version }); core . setBootstrap ( d , { release_version : version , main_commit : 'abc' , artifact_sha256 : 'x' , first_agent_id : d . store . get ( 'SELECT id FROM agents LIMIT 1' ) ? . id || 'ag_x' , first_agent_registered_at : new Date (). toISOString (), completed_at : new Date (). toISOString () }); }
function addRelease ( h , version , modules = [ 'agent' , 'portal' , 'webmaker' , 'arimail' ]) {
const art = join ( h . dataDir , 'artifacts' , version ); mkdirSync ( art , { recursive : true }); const artifacts = {};
for ( const m of modules ) { const f = ` ${ m } - ${ version } .tar.gz` ; writeFileSync ( join ( art , f ), m + version ); artifacts [ m ] = { file : f , sha256 : createHash ( 'sha256' ). update ( m + version ). digest ( 'hex' ), size : ( m + version ). length }; }
core . registerRelease ( h . d , { version , mainCommit : 'abc' , builtAt : new Date (). toISOString (), artifacts , checks : { testsPassed : true , dirty : false , provenanceClean : true , provenanceProblems : [] }, approve : true , by : 'op' });
}
/** Agent 흉내: 모든 Agent 의 작업을 받아 결과를 낸다(부모 작업이 끝날 때까지) */
async function runAgents ( h , agents , result = ( job ) => ({ status : 'done' , result : { module : job . payload . module , actualVersion : job . payload . version || '0.1.1' , health : { ok : true }, active : true } }), until = () => false , maxMs = 8000 ) {
const t0 = Date . now (); const ps = [];
while ( Date . now () - t0 < maxMs ) { ps . push ( h . d . jobs . tick (). catch (() => {})); for ( const a of agents ) { const j = core . nextAgentJob ( h . d , a . agentId ); if ( j ) core . agentJobResult ( h . d , j . id , a . agentId , result ( j )); } if ( until ()) break ; if ( ! h . d . jobs . list ({ status : [ 'queued' , 'running' ], limit : 5 }). length ) { await sleep ( 30 ); if ( ! h . d . jobs . list ({ status : [ 'queued' , 'running' ], limit : 5 }). length ) break ; } await sleep ( 15 ); }
await Promise . all ( ps );
}
await test ( '1) observability → HawkEye: 기술서·배포 순서·별칭·운영 현황 구성요소·DB 마이그레이션' , async () => {
ok ( MODULES . hawkeye && MODULES . hawkeye . label === 'HawkEye' && MODULES . hawkeye . layout === 'agent-feature' , JSON . stringify ( MODULES . hawkeye )); ok ( ! MODULES . observability , '옛 키 없음' );
eq ( descriptor ( 'observability' ). name , 'hawkeye' , '옛 이름 별칭' ); eq ( DEPLOY_ORDER [ DEPLOY_ORDER . length - 1 ], 'hawkeye' ); ok ( ! DEPLOY_ORDER . includes ( 'hal' ) && DEPLOY_ORDER [ 0 ] === 'agent' , 'HAL 은 배포 순서에 없고 agent 가 먼저' );
eq ( DEPLOYABLE_MODULES . includes ( 'hal' ), false ); ok ( DEPLOYABLE_MODULES . includes ( 'hawkeye' ));
const h = makeHal (); const op = await loginOp ( h );
// 마이그레이션: observability 행 → hawkeye (새 DB 에는 이미 적용되었으므로 옛 행을 넣고 그 마이그레이션 SQL 만 다시 실행)
const mig = MIGRATIONS . find (( m ) => m . id === 'hal_0008_hawkeye' ); ok ( mig && /hawkeye/ . test ( mig . sql ));
h . d . store . run ( 'INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?)' , 'observability' , 'srv_old' , '0.1.1' , 'ok' , 1 , 'x' , 'x' ); h . d . store . run ( 'INSERT INTO modules(name, target_version, updated_at) VALUES(?,?,?)' , 'observability' , '0.1.1' , 'x' );
h . d . store . exec ( mig . sql ); eq ( h . d . store . all ( 'SELECT module FROM module_targets WHERE server_id=?' , 'srv_old' ). map (( r ) => r . module ), [ 'hawkeye' ]); eq ( h . d . store . get ( 'SELECT name FROM modules WHERE name=?' , 'hawkeye' ). name , 'hawkeye' ); ok ( ! h . d . store . get ( 'SELECT name FROM modules WHERE name=?' , 'observability' ));
const r = await op . as ( 'GET' , '/ops/api/overview' ); ok ( r . json . components . some (( c ) => c . name === 'hawkeye' ) && ! r . json . components . some (( c ) => c . name === 'observability' ), JSON . stringify ( r . json . components . map (( c ) => c . name )));
const st = await invoke ( h . app , 'GET' , '/api/status' ); ok ( st . json . components . some (( c ) => / HawkEye / . test ( c . name )), JSON . stringify ( st . json . components ));
const ov = await op . as ( 'GET' , '/ops/' ); ok ( ov . text . includes ( 'HawkEye' ));
});
await test ( '4·6) 배포 릴리스: HAL 은 배포 대상 아님, HAL 먼저 규칙은 "HAL 이 더 오래되지 않으면" 통과, 대상 서버 비우면 역할 전체로, 없으면 즉시 no_targets' , async () => {
const h = makeHal (); const op = await loginOp ( h ); const web = await addServer ( h , { name : '웹1' , role : 'web' }); const mail = await addServer ( h , { name : '메일1' , role : 'mail' }); bootstrapDone ( h . d , '0.3.17' ); addRelease ( h , '0.3.10' ); addRelease ( h , '0.3.17' );
let r = await op . as ( 'GET' , '/ops/api/releases' ); ok ( ! r . json . modules . includes ( 'hal' ) && r . json . modules . includes ( 'hawkeye' ), JSON . stringify ( r . json . modules )); eq ( r . json . deployOrder [ 0 ], 'agent' );
ok ( r . json . latest && r . json . latest . portal === '0.3.17' && r . json . latest . agent === '0.3.17' , JSON . stringify ( r . json . latest )); ok ( r . json . guides && r . json . guides . webmaker && /agent/ . test ( r . json . guides . webmaker . requires . join ( ',' )) && r . json . guides . portal . singleton === true , JSON . stringify ( r . json . guides && r . json . guides . webmaker ));
ok ( r . json . profiles && r . json . profiles . some (( p ) => p . id === 'webhosting' ), '프로필' );
r = await op . as ( 'POST' , '/ops/api/modules/hal/deploy' , { version : '0.3.17' , serverIds : [ web . serverId ] }); eq ( r . status , 400 ); eq ( r . json . error , 'hal_not_deployable' );
// 대상 비움 → 역할에 맞는 서버 전체(portal: control·web → 웹1), agent: 전체
r = await op . as ( 'POST' , '/ops/api/modules/portal/deploy' , { version : '0.3.17' , serverIds : [] }); eq ( r . status , 200 , JSON . stringify ( r . json )); eq ( r . json . job . subject ? r . json . job . subject . serverIds : r . json . targets , [ web . serverId ]); ok ( r . json . targets && r . json . targets . length === 1 );
r = await op . as ( 'POST' , '/ops/api/modules/agent/deploy' , { version : '0.3.17' , serverIds : [], nonce : '2' }); eq ( r . status , 200 , JSON . stringify ( r . json )); eq ( r . json . targets . sort (), [ web . serverId , mail . serverId ]. sort ());
h . d . store . run ( 'UPDATE servers SET status=? WHERE id=?' , 'maintenance' , mail . serverId ); // mail 서버가 준비 상태가 아니면 arimail 의 기본 대상이 없다
r = await op . as ( 'POST' , '/ops/api/modules/arimail/deploy' , { version : '0.3.17' , serverIds : [] }); eq ( r . status , 400 ); eq ( r . json . error , 'no_targets' ); ok ( /arimail/ . test ( r . json . message )); h . d . store . run ( 'UPDATE servers SET status=? WHERE id=?' , 'ready' , mail . serverId );
// HAL 먼저: HAL 0.3.17 실행 중에 0.3.10 릴리스(더 오래됨) 배포는 precheck 통과(그 뒤 Agent 결과로 진행)
const j = h . d . jobs . enqueue ({ kind : 'module.deploy' , subject : { module : 'portal' , version : '0.3.10' , serverIds : [ web . serverId ] }, createdBy : 'op' , actorType : 'operator' }). job ;
await runAgents ( h , [ web ]); const jd = h . d . jobs . get ( j . id ); ok ( jd . error_code !== 'hal_first' , `hal_first 아님: ${ jd . error_code } ${ jd . error } ` ); eq ( jd . status , 'done' , JSON . stringify ( jd . error ));
// HAL 이 더 오래됐으면 hal_first
core . setBootstrap ( h . d , { hal_running_version : '0.3.9' }); const j2 = h . d . jobs . enqueue ({ kind : 'module.deploy' , subject : { module : 'portal' , version : '0.3.10' , serverIds : [ web . serverId ] }, createdBy : 'op' , actorType : 'operator' , idempotencyKey : 'x2' }). job ; await h . d . jobs . drain (); eq ( h . d . jobs . get ( j2 . id ). error_code , 'hal_first' );
// 화면: 기본은 모듈별 최신 릴리스·안내·상호 연결, 과거 버전은 접힘, HAL 배포 단추 없음
r = await op . as ( 'GET' , '/ops/releases' ); eq ( r . status , 200 ); for ( const s of [ '최신 릴리스' , '과거 버전 보기' , 'HawkEye' , '상호 연결' , 'HAL 은 배포 대상이 아닙니다' ]) ok ( r . text . includes ( s ), '릴리스 화면 ' + s ); ok ( ! r . text . includes ( 'data-m="hal"' ), 'HAL 배포 단추 없음' ); ok ( r . text . includes ( 'data-act="module-deploy"' ) && r . text . includes ( 'data-m="portal"' ));
});
await test ( '2) 역할별 통합 배포: 프로필대로 모듈을 순서대로 배포하고 서버에 프로필·역할·슬롯을 기록, 포털은 하나만(이미 있으면 거부) + 포털 이전 작업, 슬롯 기반 배치' , async () => {
const h = makeHal (); const op = await loginOp ( h ); const s1 = await addServer ( h , { name : '새서버' , role : 'web' }); const s2 = await addServer ( h , { name : '컨트롤' , role : 'control' }); bootstrapDone ( h . d , '0.3.17' ); addRelease ( h , '0.3.17' , [ 'agent' , 'portal' , 'webmaker' , 'arimail' ]);
ok ( SERVER_PROFILES . webmaker && SERVER_PROFILES . webmaker . modules . includes ( 'webmaker' ) && SERVER_PROFILES . webmaker . modules . includes ( 'hawkeye' ) && SERVER_PROFILES . webmaker . modules [ 0 ] === 'agent' , JSON . stringify ( SERVER_PROFILES . webmaker ));
let r = await op . as ( 'GET' , '/ops/api/provision/profiles' ); eq ( r . status , 200 ); ok ( r . json . profiles . map (( p ) => p . id ). sort (). join ( ',' ) === 'arimail,backup,portal,webhosting,webmaker' );
// webmaker 프로필을 새서버에: agent → webmaker → hawkeye 순서로 자식 작업, 서버 profile·role·slots 기록
r = await op . as ( 'POST' , '/ops/api/provision' , { serverId : s1 . serverId , profile : 'webmaker' , version : '0.3.17' }); eq ( r . status , 200 , JSON . stringify ( r . json )); const pj = r . json . job ;
const seen = []; await runAgents ( h , [ s1 , s2 ], ( job ) => { seen . push ( job . payload . module ); return { status : 'done' , result : { module : job . payload . module , actualVersion : job . payload . version || '0.1.1' , health : { ok : true }, active : true } }; });
const done1 = h . d . jobs . get ( pj . id ); eq ( done1 . status , 'done' , JSON . stringify ({ e : done1 . error , c : done1 . error_code , steps : done1 . steps })); eq ( seen , [ 'agent' , 'webmaker' , 'hawkeye' ], '순서' );
const srv = core . getServer ( h . d , s1 . serverId ); eq ( srv . profile , 'webmaker' ); eq ( srv . role , 'web' ); ok ( srv . slots && srv . slots . webmaker === 1 , JSON . stringify ( srv . slots ));
eq ( h . d . store . all ( 'SELECT module, actual_version FROM module_targets WHERE server_id=? ORDER BY module' , s1 . serverId ). map (( t ) => t . module + ':' + t . actual_version ), [ 'agent:0.3.17' , 'hawkeye:0.3.17' , 'webmaker:0.3.17' ]);
ok ( h . d . jobs . list ({ parentId : pj . id , limit : 10 }). length === 3 , '자식 작업 3개' );
// 포털: 컨트롤 서버에 설치 → 다른 서버에 또 설치는 거부(portal_exists)
r = await op . as ( 'POST' , '/ops/api/provision' , { serverId : s2 . serverId , profile : 'portal' , version : '0.3.17' }); eq ( r . status , 200 , JSON . stringify ( r . json )); await runAgents ( h , [ s1 , s2 ]); eq ( h . d . jobs . get ( r . json . job . id ). status , 'done' );
r = await op . as ( 'POST' , '/ops/api/provision' , { serverId : s1 . serverId , profile : 'portal' , version : '0.3.17' }); eq ( r . status , 409 ); eq ( r . json . error , 'portal_exists' ); ok ( /이전/ . test ( r . json . message ));
// 포털 이전: 새 서버에 설치 → 운영자 확인(데이터 이전·DNS) 대기 → 재개 → 옛 서버 비활성
stale : r = await op . as ( 'POST' , '/ops/api/portal/relocate' , { fromServerId : s2 . serverId , toServerId : s1 . serverId , version : '0.3.17' , confirmImpact : true , confirmIdentifiers : [ s2 . serverId , s1 . serverId ], confirmExecute : true }); eq ( r . status , 200 , JSON . stringify ( r . json )); const rj = r . json . job ;
await runAgents ( h , [ s1 , s2 ], undefined , () => h . d . jobs . get ( rj . id ). status === 'needs_user' ); for ( let i = 0 ; i < 100 && h . d . jobs . get ( rj . id ). status !== 'needs_user' ; i ++ ) await sleep ( 20 );
eq ( h . d . jobs . get ( rj . id ). status , 'needs_user' , JSON . stringify ( h . d . jobs . get ( rj . id ). error )); ok ( /DNS|데이터/ . test ( h . d . jobs . get ( rj . id ). user_message ));
eq ( h . d . store . get ( 'SELECT actual_version FROM module_targets WHERE module=? AND server_id=?' , 'portal' , s1 . serverId ). actual_version , '0.3.17' , '새 서버에 설치됨' );
r = await op . as ( 'POST' , `/ops/api/jobs/ ${ rj . id } /resume` ); eq ( r . status , 200 ); await runAgents ( h , [ s1 , s2 ]); eq ( h . d . jobs . get ( rj . id ). status , 'done' , JSON . stringify ( h . d . jobs . get ( rj . id ). error ));
eq ( h . d . store . get ( 'SELECT active FROM module_targets WHERE module=? AND server_id=?' , 'portal' , s2 . serverId ). active , 0 , '옛 포털 비활성' ); eq ( h . d . store . get ( 'SELECT active FROM module_targets WHERE module=? AND server_id=?' , 'portal' , s1 . serverId ). active , 1 );
// 슬롯 기반 배치: web 서버 두 대 중 슬롯이 찬 서버는 제외
const w2 = await addServer ( h , { name : '웹2' , role : 'web' , sitesHosted : 1 }); await op . as ( 'PATCH' , `/ops/api/servers/ ${ w2 . serverId } ` , { slots : { sites : 1 } }); const w3 = await addServer ( h , { name : '웹3' , role : 'web' }); await op . as ( 'PATCH' , `/ops/api/servers/ ${ w3 . serverId } ` , { slots : { sites : 10 } });
await op . as ( 'PATCH' , `/ops/api/servers/ ${ s1 . serverId } ` , { accepts_new : 0 });
const pick = core . pickServer ( h . d , { role : 'web' }); eq ( pick . serverId , w3 . serverId , '여유 슬롯 있는 서버' ); ok ( core . publicServer ( h . d , core . getServer ( h . d , w2 . serverId )). slotsFree . sites === 0 );
// 모르는 프로필·준비 안 된 서버
r = await op . as ( 'POST' , '/ops/api/provision' , { serverId : s1 . serverId , profile : 'nope' , version : '0.3.17' }); eq ( r . status , 400 );
const pend = core . createServer ( h . d , { name : '대기' , role : 'web' }); r = await op . as ( 'POST' , '/ops/api/provision' , { serverId : pend . id , profile : 'webhosting' , version : '0.3.17' }); eq ( r . status , 409 ); eq ( r . json . error , 'server_not_ready' );
});
await test ( '3·5·7) 화면: 부분 갱신 diff(순수)·ops.js 정적 계약, 작업 진행 창(dock), 서버 선택 목록 렌더러' , async () => {
const P = await import ( '../hal/ui/public/dom-patch.mjs' );
eq ( P . diffChildren ([ '<p>a</p>' , '<p>b</p>' ], [ '<p>a</p>' , '<p>b</p>' ]), [], '같으면 변경 없음' );
eq ( P . diffChildren ([ '<p>a</p>' , '<p>b</p>' ], [ '<p>a</p>' , '<p>B</p>' , '<p>c</p>' ]), [{ i : 1 , op : 'replace' , html : '<p>B</p>' }, { i : 2 , op : 'append' , html : '<p>c</p>' }]);
eq ( P . diffChildren ([ '<p>a</p>' , '<p>b</p>' , '<p>c</p>' ], [ '<p>a</p>' ]), [{ i : 1 , op : 'remove' }, { i : 2 , op : 'remove' }]. reverse (), '뒤에서부터 제거' );
eq ( P . splitTopLevel ( '<div>x<p>y</p></div><p>z</p>text' ), [ '<div>x<p>y</p></div>' , '<p>z</p>' , 'text' ]);
const js = readFileSync ( join ( ROOT , 'hal' , 'ui' , 'public' , 'ops.js' ), 'utf8' ); ok ( js . includes ( "from './dom-patch.mjs'" ) && js . includes ( 'patchHtml(' ), 'ops.js 가 부분 갱신을 쓴다' ); ok ( ! /overview: async \(\) => \{ try \{ const o = await api\('GET', '\/ops\/api\/overview'\); body\(\)\.innerHTML/ . test ( js ), '운영 현황 주기 갱신이 통째로 innerHTML 을 바꾸지 않는다' );
const ai = readFileSync ( join ( ROOT , 'hal' , 'ui' , 'public' , 'ai.js' ), 'utf8' ); ok ( ai . includes ( 'patchHtml(' ) || ai . includes ( 'patchMessages(' ), 'AI 패널도 부분 갱신' );
// dock
const dock = R . jobsDockHtml ({ active : [{ id : 'j_1' , kind : 'module.deploy' , label : 'portal 0.3.17 배포' , status : 'running' , progress : 40 , currentStep : { title : '대상별 결과 확인' }, updatedAt : new Date (). toISOString () }], history : [{ id : 'j_0' , kind : 'module.health' , label : 'x <b>' , status : 'done' , updatedAt : new Date (). toISOString () }] });
ok ( dock . includes ( 'j_1' ) && dock . includes ( '40%' ) && dock . includes ( '대상별 결과 확인' ) && dock . includes ( 'x <b>' ) && ! dock . includes ( 'x <b>' ), dock . slice ( 0 , 300 ));
ok ( R . jobsDockHtml ({ active : [], history : [] }). includes ( '진행 중인 작업이 없습니다' ));
// 서버 선택 목록(라디오/체크): 역할·상태·버전 표시, 준비 안 된 서버는 비활성
const servers = [{ id : 'srv_a' , name : '웹1' , role : 'web' , status : 'ready' , agentVersion : '0.3.10' , profile : 'webhosting' , modules : { agent : { actual : '0.3.10' } } }, { id : 'srv_b' , name : '대기<' , role : 'mail' , status : 'pending' , agentVersion : null , modules : {} }];
const radio = R . serverChoiceHtml ( servers , { name : 'serverId' }); ok ( radio . includes ( 'type="radio"' ) && radio . includes ( 'value="srv_a"' ) && radio . includes ( '대기<' ) && /disabled/ . test ( radio ) && radio . includes ( 'webhosting' ), radio . slice ( 0 , 400 ));
const checks = R . serverChoiceHtml ( servers , { name : 'serverIds' , multi : true , eligible : [ 'srv_a' ] }); ok ( checks . includes ( 'type="checkbox"' ) && /value="srv_a"[^>]*checked/ . test ( checks ) && ! /value="srv_b"[^>]*checked/ . test ( checks ));
ok ( R . serverChoiceHtml ([], { name : 'serverId' }). includes ( '서버가 없습니다' ));
// 공통 틀: dock 과 스크립트
const h = makeHal (); const op = await loginOp ( h ); const r = await op . as ( 'GET' , '/ops/servers' ); for ( const s of [ 'id="ops-dock"' , '진행 중' , '히스토리' , 'data-act="provision"' ]) ok ( r . text . includes ( s ), '틀 ' + s );
const sv = await op . as ( 'GET' , '/ops/api/servers' ); ok ( Array . isArray ( sv . json . servers ));
});
await test ( '버전·문서(0.3.17)' , async () => {
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
const h = makeHal (); eq ( JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )). version , '0.4.0' ); eq ( h . d . version , '0.4.0' );
HAL 0.3.17: HawkEye 이름 변경(observability → hawkeye, 별칭·마이그레이션), 역할별 통합 배포(server.provision: webmaker·webhosting·arimail·backup·portal 프로필, 순서 배포, 역할·프로필·슬롯 기록, 포털 싱글턴·포털 이전 portal.relocate, 슬롯 기반 배치), 화면 부분 갱신(dom-patch, 운영 현황·서버·작업·AI 기록·dock), 배포 릴리스 화면 재구성(HAL 배포 제외 hal_not_deployable, HAL 먼저 규칙 완화 ≥, 모듈별 최신 릴리스·설명·필요한 것·제공·설치 방법·현재 배치·상호 연결·프로필, 과거 버전 보기), 작업 진행 창(dock), agent 배포 "처리 대기" 원인 수정(빈 대상 → 역할 전체, 없으면 즉시 no_targets), 서버 라디오/체크 선택 목록, 개발 지시 v0.3.17(Oz)·VERSION_HISTORY 0.3.17 — test/hal-0317.test.mjs RED → GREEN 5/5, 전체 30파일 204건 통과
2026-10-29 03:32:46 +00:00
ok ( readFileSync ( join ( ROOT , 'Ariweb-Suitle_Dev-Order_v0.3.17.md' ), 'utf8' ). includes ( 'Oz' )); ok ( readFileSync ( join ( ROOT , 'VERSION_HISTORY.md' ), 'utf8' ). includes ( '## 0.3.17' ));
ok ( readFileSync ( join ( ROOT , 'hal' , 'ai' , 'skill.md' ), 'utf8' ). includes ( 'HawkEye' ) && ! /observability\b(?! 는 옛 이름)/ . test ( readFileSync ( join ( ROOT , 'hal' , 'ai' , 'skill.md' ), 'utf8' ). replace ( '옛 이름 observability' , '' )), '스킬 문서도 HawkEye' );
});
done ();